release: 2026.05.1 — distributed probing, config-as-code, TUI polish #15
@@ -112,6 +112,7 @@ func main() {
|
|||||||
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
|
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
monitor.InitHistoryFromStore()
|
||||||
monitor.StartEngine()
|
monitor.StartEngine()
|
||||||
|
|
||||||
server.Start(server.ServerConfig{
|
server.Start(server.ServerConfig{
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ type User struct {
|
|||||||
Role string
|
Role string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 5: Backup Structure
|
type CheckRecord struct {
|
||||||
|
SiteID int
|
||||||
|
LatencyNs int64
|
||||||
|
IsUp bool
|
||||||
|
CheckedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Backup struct {
|
type Backup struct {
|
||||||
Sites []Site `json:"sites"`
|
Sites []Site `json:"sites"`
|
||||||
Alerts []AlertConfig `json:"alerts"`
|
Alerts []AlertConfig `json:"alerts"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package monitor
|
package monitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"go-upkeep/internal/store"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,31 @@ var (
|
|||||||
historyMu sync.RWMutex
|
historyMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func InitHistoryFromStore() {
|
||||||
|
s := store.Get()
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := s.LoadAllHistory(maxHistoryLen)
|
||||||
|
historyMu.Lock()
|
||||||
|
defer historyMu.Unlock()
|
||||||
|
for siteID, records := range all {
|
||||||
|
h := &SiteHistory{}
|
||||||
|
for _, r := range records {
|
||||||
|
h.TotalChecks++
|
||||||
|
if r.IsUp {
|
||||||
|
h.UpChecks++
|
||||||
|
}
|
||||||
|
h.Latencies = append(h.Latencies, time.Duration(r.LatencyNs))
|
||||||
|
h.Statuses = append(h.Statuses, r.IsUp)
|
||||||
|
}
|
||||||
|
histories[siteID] = h
|
||||||
|
}
|
||||||
|
if len(all) > 0 {
|
||||||
|
AddLog("Loaded check history from database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RecordCheck(siteID int, latency time.Duration, isUp bool) {
|
func RecordCheck(siteID int, latency time.Duration, isUp bool) {
|
||||||
historyMu.Lock()
|
historyMu.Lock()
|
||||||
defer historyMu.Unlock()
|
defer historyMu.Unlock()
|
||||||
@@ -43,6 +69,10 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) {
|
|||||||
if len(h.Statuses) > maxHistoryLen {
|
if len(h.Statuses) > maxHistoryLen {
|
||||||
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
|
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s := store.Get(); s != nil {
|
||||||
|
go s.SaveCheck(siteID, latency.Nanoseconds(), isUp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetHistory(siteID int) (SiteHistory, bool) {
|
func GetHistory(siteID int) (SiteHistory, bool) {
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ func checkByID(id int) {
|
|||||||
case "dns":
|
case "dns":
|
||||||
checkDNS(site)
|
checkDNS(site)
|
||||||
case "group":
|
case "group":
|
||||||
// groups don't perform checks
|
checkGroup(site)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +437,44 @@ func checkPort(site models.Site) {
|
|||||||
handleStatusChange(updatedSite, "UP", 0, latency)
|
handleStatusChange(updatedSite, "UP", 0, latency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkGroup(site models.Site) {
|
||||||
|
Mutex.RLock()
|
||||||
|
status := "UP"
|
||||||
|
hasChildren := false
|
||||||
|
allPaused := true
|
||||||
|
for _, child := range LiveState {
|
||||||
|
if child.ParentID != site.ID || child.Type == "group" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasChildren = true
|
||||||
|
if !child.Paused {
|
||||||
|
allPaused = false
|
||||||
|
}
|
||||||
|
if child.Paused {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if child.Status == "DOWN" || child.Status == "SSL EXP" {
|
||||||
|
status = "DOWN"
|
||||||
|
} else if child.Status == "PENDING" && status != "DOWN" {
|
||||||
|
status = "PENDING"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mutex.RUnlock()
|
||||||
|
|
||||||
|
if !hasChildren {
|
||||||
|
status = "PENDING"
|
||||||
|
}
|
||||||
|
|
||||||
|
Mutex.Lock()
|
||||||
|
s := LiveState[site.ID]
|
||||||
|
s.Status = status
|
||||||
|
if hasChildren && allPaused {
|
||||||
|
s.Paused = true
|
||||||
|
}
|
||||||
|
LiveState[site.ID] = s
|
||||||
|
Mutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func checkDNS(site models.Site) {
|
func checkDNS(site models.Site) {
|
||||||
host := site.Hostname
|
host := site.Hostname
|
||||||
if host == "" {
|
if host == "" {
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
|||||||
<script>
|
<script>
|
||||||
var lastUpdate = null;
|
var lastUpdate = null;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.appendChild(document.createTextNode(s));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function cssClass(status) {
|
function cssClass(status) {
|
||||||
return status.replace(/\s+/g, '-');
|
return status.replace(/\s+/g, '-');
|
||||||
}
|
}
|
||||||
@@ -234,13 +240,13 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
|||||||
var s = sites[i];
|
var s = sites[i];
|
||||||
var st = s.Paused ? 'PAUSED' : s.Status;
|
var st = s.Paused ? 'PAUSED' : s.Status;
|
||||||
var cls = cssClass(st);
|
var cls = cssClass(st);
|
||||||
var meta = s.Type + ' | ' + (s.Type === 'http' ? s.URL : 'Heartbeat Monitor');
|
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
|
||||||
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
||||||
html += '<div class="card"><div class="info">' +
|
html += '<div class="card"><div class="info">' +
|
||||||
'<div class="name">' + s.Name + '</div>' +
|
'<div class="name">' + esc(s.Name) + '</div>' +
|
||||||
'<div class="meta">' + meta + '</div>' +
|
'<div class="meta">' + meta + '</div>' +
|
||||||
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
|
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
|
||||||
'</div><div class="status ' + cls + '">' + st + '</div></div>';
|
'</div><div class="status ' + cls + '">' + esc(st) + '</div></div>';
|
||||||
}
|
}
|
||||||
c.innerHTML = html;
|
c.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ func (p *PostgresStore) Init() error {
|
|||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
role TEXT DEFAULT 'user'
|
role TEXT DEFAULT 'user'
|
||||||
);`,
|
);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS check_history (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
latency_ns BIGINT,
|
||||||
|
is_up BOOLEAN,
|
||||||
|
checked_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);`,
|
||||||
}
|
}
|
||||||
for _, q := range queries {
|
for _, q := range queries {
|
||||||
if _, err := p.db.Exec(q); err != nil {
|
if _, err := p.db.Exec(q); err != nil {
|
||||||
@@ -63,6 +70,8 @@ func (p *PostgresStore) Init() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.db.Exec("CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)")
|
||||||
|
|
||||||
migrations := []string{
|
migrations := []string{
|
||||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
|
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
|
||||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
|
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
|
||||||
@@ -184,7 +193,38 @@ func (p *PostgresStore) DeleteUser(id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PHASE 5 ---
|
func (p *PostgresStore) SaveCheck(siteID int, latencyNs int64, isUp bool) {
|
||||||
|
p.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES ($1, $2, $3)", siteID, latencyNs, isUp)
|
||||||
|
p.db.Exec(`DELETE FROM check_history WHERE site_id = $1 AND id NOT IN (
|
||||||
|
SELECT id FROM check_history WHERE site_id = $1 ORDER BY checked_at DESC LIMIT 1000
|
||||||
|
)`, siteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PostgresStore) LoadAllHistory(limit int) map[int][]models.CheckRecord {
|
||||||
|
result := make(map[int][]models.CheckRecord)
|
||||||
|
rows, err := p.db.Query(`
|
||||||
|
SELECT site_id, latency_ns, is_up FROM (
|
||||||
|
SELECT site_id, latency_ns, is_up,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
||||||
|
FROM check_history
|
||||||
|
) sub WHERE rn <= $1`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.CheckRecord
|
||||||
|
rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp)
|
||||||
|
result[r.SiteID] = append(result[r.SiteID], r)
|
||||||
|
}
|
||||||
|
for id, records := range result {
|
||||||
|
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
records[i], records[j] = records[j], records[i]
|
||||||
|
}
|
||||||
|
result[id] = records
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (p *PostgresStore) ExportData() models.Backup {
|
func (p *PostgresStore) ExportData() models.Backup {
|
||||||
return models.Backup{
|
return models.Backup{
|
||||||
|
|||||||
@@ -57,7 +57,15 @@ func (s *SQLiteStore) Init() error {
|
|||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
role TEXT DEFAULT 'user'
|
role TEXT DEFAULT 'user'
|
||||||
);`
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS check_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL,
|
||||||
|
latency_ns INTEGER,
|
||||||
|
is_up BOOLEAN,
|
||||||
|
checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC);`
|
||||||
_, err = s.db.Exec(createTables)
|
_, err = s.db.Exec(createTables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -204,7 +212,38 @@ func (s *SQLiteStore) DeleteUser(id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PHASE 5 ---
|
func (s *SQLiteStore) SaveCheck(siteID int, latencyNs int64, isUp bool) {
|
||||||
|
s.db.Exec("INSERT INTO check_history (site_id, latency_ns, is_up) VALUES (?, ?, ?)", siteID, latencyNs, isUp)
|
||||||
|
s.db.Exec(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
|
||||||
|
)`, siteID, siteID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) LoadAllHistory(limit int) map[int][]models.CheckRecord {
|
||||||
|
result := make(map[int][]models.CheckRecord)
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT site_id, latency_ns, is_up FROM (
|
||||||
|
SELECT site_id, latency_ns, is_up,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
||||||
|
FROM check_history
|
||||||
|
) WHERE rn <= ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.CheckRecord
|
||||||
|
rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp)
|
||||||
|
result[r.SiteID] = append(result[r.SiteID], r)
|
||||||
|
}
|
||||||
|
for id, records := range result {
|
||||||
|
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
records[i], records[j] = records[j], records[i]
|
||||||
|
}
|
||||||
|
result[id] = records
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLiteStore) ExportData() models.Backup {
|
func (s *SQLiteStore) ExportData() models.Backup {
|
||||||
return models.Backup{
|
return models.Backup{
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ type Store interface {
|
|||||||
UpdateUser(id int, username, publicKey, role string) error
|
UpdateUser(id int, username, publicKey, role string) error
|
||||||
DeleteUser(id int) error
|
DeleteUser(id int) error
|
||||||
|
|
||||||
// Phase 5: Backup & Restore
|
// History
|
||||||
|
SaveCheck(siteID int, latencyNs int64, isUp bool)
|
||||||
|
LoadAllHistory(limit int) map[int][]models.CheckRecord
|
||||||
|
|
||||||
|
// Backup & Restore
|
||||||
ExportData() models.Backup
|
ExportData() models.Backup
|
||||||
ImportData(data models.Backup) error
|
ImportData(data models.Backup) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ func (m Model) viewAlertsTab() string {
|
|||||||
for i := m.tableOffset; i < end; i++ {
|
for i := m.tableOffset; i < end; i++ {
|
||||||
alert := m.alerts[i]
|
alert := m.alerts[i]
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", alert.ID),
|
fmt.Sprintf("%d", i+1),
|
||||||
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)),
|
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)),
|
||||||
fmtAlertType(alert.Type),
|
fmtAlertType(alert.Type),
|
||||||
fmtAlertConfig(struct {
|
fmtAlertConfig(struct {
|
||||||
@@ -127,7 +127,7 @@ func (m Model) viewAlertsTab() string {
|
|||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderStyle(alertBorderStyle).
|
BorderStyle(alertBorderStyle).
|
||||||
Width(tableWidth).
|
Width(tableWidth).
|
||||||
Headers("ID", "NAME", "TYPE", "CONFIG").
|
Headers("#", "NAME", "TYPE", "CONFIG").
|
||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
if row == table.HeaderRow {
|
if row == table.HeaderRow {
|
||||||
|
|||||||
+141
-17
@@ -34,6 +34,11 @@ var (
|
|||||||
|
|
||||||
siteBorderStyle = lipgloss.NewStyle().
|
siteBorderStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#444"))
|
Foreground(lipgloss.Color("#444"))
|
||||||
|
|
||||||
|
siteGroupStyle = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#7D56F4"))
|
||||||
)
|
)
|
||||||
|
|
||||||
type siteFormData struct {
|
type siteFormData struct {
|
||||||
@@ -50,6 +55,7 @@ type siteFormData struct {
|
|||||||
Timeout string
|
Timeout string
|
||||||
Description string
|
Description string
|
||||||
IgnoreTLS bool
|
IgnoreTLS bool
|
||||||
|
GroupID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func latencySparkline(latencies []time.Duration, width int) string {
|
func latencySparkline(latencies []time.Duration, width int) string {
|
||||||
@@ -222,10 +228,42 @@ func (m Model) viewSitesTab() string {
|
|||||||
selectedVisual := m.cursor - m.tableOffset
|
selectedVisual := m.cursor - m.tableOffset
|
||||||
|
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
|
var groupRows []int
|
||||||
for i := m.tableOffset; i < end; i++ {
|
for i := m.tableOffset; i < end; i++ {
|
||||||
site := m.sites[i]
|
site := m.sites[i]
|
||||||
hist, _ := monitor.GetHistory(site.ID)
|
|
||||||
|
|
||||||
|
if site.Type == "group" {
|
||||||
|
groupRows = append(groupRows, i-m.tableOffset)
|
||||||
|
arrow := "▾"
|
||||||
|
if m.collapsed[site.ID] {
|
||||||
|
arrow = "▸"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
strconv.Itoa(i + 1),
|
||||||
|
m.zones.Mark(fmt.Sprintf("site-%d", i), arrow+" "+limitStr(site.Name, 11)),
|
||||||
|
"group",
|
||||||
|
fmtStatus(site.Status, site.Paused),
|
||||||
|
subtleStyle.Render("—"),
|
||||||
|
subtleStyle.Render("—"),
|
||||||
|
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
|
||||||
|
subtleStyle.Render("-"),
|
||||||
|
subtleStyle.Render("—"),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := site.Name
|
||||||
|
if site.ParentID > 0 {
|
||||||
|
prefix := "├"
|
||||||
|
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
||||||
|
prefix = "└"
|
||||||
|
}
|
||||||
|
name = prefix + " " + limitStr(name, 11)
|
||||||
|
} else {
|
||||||
|
name = limitStr(name, 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
hist, _ := monitor.GetHistory(site.ID)
|
||||||
var spark string
|
var spark string
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
||||||
@@ -234,8 +272,8 @@ func (m Model) viewSitesTab() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
strconv.Itoa(site.ID),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
||||||
site.Type,
|
site.Type,
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused),
|
||||||
fmtLatency(site.Latency),
|
fmtLatency(site.Latency),
|
||||||
@@ -246,6 +284,15 @@ func (m Model) viewSitesTab() string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isGroupRow := func(row int) bool {
|
||||||
|
for _, g := range groupRows {
|
||||||
|
if g == row {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
tableWidth := m.termWidth - 6
|
tableWidth := m.termWidth - 6
|
||||||
if tableWidth < 40 {
|
if tableWidth < 40 {
|
||||||
tableWidth = 40
|
tableWidth = 40
|
||||||
@@ -255,7 +302,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderStyle(siteBorderStyle).
|
BorderStyle(siteBorderStyle).
|
||||||
Width(tableWidth).
|
Width(tableWidth).
|
||||||
Headers("ID", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY").
|
Headers("#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY").
|
||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
if row == table.HeaderRow {
|
if row == table.HeaderRow {
|
||||||
@@ -264,6 +311,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
if row == selectedVisual {
|
if row == selectedVisual {
|
||||||
return siteSelectedStyle
|
return siteSelectedStyle
|
||||||
}
|
}
|
||||||
|
if isGroupRow(row) {
|
||||||
|
return siteGroupStyle
|
||||||
|
}
|
||||||
return siteCellStyle
|
return siteCellStyle
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -278,6 +328,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
Retries: "0",
|
Retries: "0",
|
||||||
Timeout: "5",
|
Timeout: "5",
|
||||||
Port: "0",
|
Port: "0",
|
||||||
|
GroupID: "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.editID > 0 {
|
if m.editID > 0 {
|
||||||
@@ -296,6 +347,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
m.siteFormData.Timeout = strconv.Itoa(site.Timeout)
|
m.siteFormData.Timeout = strconv.Itoa(site.Timeout)
|
||||||
m.siteFormData.Description = site.Description
|
m.siteFormData.Description = site.Description
|
||||||
m.siteFormData.IgnoreTLS = site.IgnoreTLS
|
m.siteFormData.IgnoreTLS = site.IgnoreTLS
|
||||||
|
m.siteFormData.GroupID = strconv.Itoa(site.ParentID)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +363,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.Type == "group" && s.ID != m.editID {
|
||||||
|
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.huhForm = huh.NewForm(
|
m.huhForm = huh.NewForm(
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("Monitor Name").
|
huh.NewInput().Title("Monitor Name").
|
||||||
@@ -331,12 +390,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
huh.NewOption("DNS", "dns"),
|
huh.NewOption("DNS", "dns"),
|
||||||
huh.NewOption("Group", "group"),
|
huh.NewOption("Group", "group"),
|
||||||
).Value(&m.siteFormData.SiteType),
|
).Value(&m.siteFormData.SiteType),
|
||||||
|
huh.NewSelect[string]().Title("Alert Channel").
|
||||||
|
Options(alertOpts...).
|
||||||
|
Value(&m.siteFormData.AlertID),
|
||||||
|
).Title("Monitor Settings"),
|
||||||
|
huh.NewGroup(
|
||||||
huh.NewInput().Title("URL").
|
huh.NewInput().Title("URL").
|
||||||
Placeholder("https://example.com").
|
Placeholder("https://example.com").
|
||||||
Description("Required for HTTP monitors").
|
Description("Required for HTTP monitors").
|
||||||
Value(&m.siteFormData.URL).
|
Value(&m.siteFormData.URL).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if m.siteFormData.SiteType == "push" {
|
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -356,12 +420,23 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
}),
|
}),
|
||||||
huh.NewInput().Title("Check Interval (seconds)").
|
huh.NewInput().Title("Check Interval (seconds)").
|
||||||
Placeholder("60").
|
Placeholder("60").
|
||||||
Value(&m.siteFormData.Interval),
|
Value(&m.siteFormData.Interval).
|
||||||
huh.NewSelect[string]().Title("Alert Channel").
|
Validate(func(s string) error {
|
||||||
Options(alertOpts...).
|
if m.siteFormData.SiteType == "group" {
|
||||||
Value(&m.siteFormData.AlertID),
|
return nil
|
||||||
).Title("Monitor Settings"),
|
}
|
||||||
huh.NewGroup(
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 5 {
|
||||||
|
return fmt.Errorf("minimum interval is 5 seconds")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewSelect[string]().Title("Parent Group").
|
||||||
|
Options(groupOpts...).
|
||||||
|
Value(&m.siteFormData.GroupID),
|
||||||
huh.NewInput().Title("Hostname / IP").
|
huh.NewInput().Title("Hostname / IP").
|
||||||
Placeholder("10.0.0.1").
|
Placeholder("10.0.0.1").
|
||||||
Description("Target for ping/port/DNS monitors").
|
Description("Target for ping/port/DNS monitors").
|
||||||
@@ -369,26 +444,73 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
huh.NewInput().Title("Port").
|
huh.NewInput().Title("Port").
|
||||||
Placeholder("0").
|
Placeholder("0").
|
||||||
Description("Target port for TCP port monitors").
|
Description("Target port for TCP port monitors").
|
||||||
Value(&m.siteFormData.Port),
|
Value(&m.siteFormData.Port).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 0 || v > 65535 {
|
||||||
|
return fmt.Errorf("port must be 0-65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
huh.NewInput().Title("Timeout (seconds)").
|
huh.NewInput().Title("Timeout (seconds)").
|
||||||
Placeholder("5").
|
Placeholder("5").
|
||||||
Value(&m.siteFormData.Timeout),
|
Value(&m.siteFormData.Timeout).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if m.siteFormData.SiteType == "group" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 1 || v > 300 {
|
||||||
|
return fmt.Errorf("timeout must be 1-300 seconds")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
huh.NewInput().Title("Description").
|
huh.NewInput().Title("Description").
|
||||||
Placeholder("Optional description").
|
Placeholder("Optional description").
|
||||||
Value(&m.siteFormData.Description),
|
Value(&m.siteFormData.Description),
|
||||||
).Title("Connection"),
|
).Title("Connection").WithHideFunc(func() bool {
|
||||||
|
return m.siteFormData.SiteType == "group"
|
||||||
|
}),
|
||||||
huh.NewGroup(
|
huh.NewGroup(
|
||||||
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
huh.NewConfirm().Title("Monitor SSL Certificate?").
|
||||||
Value(&m.siteFormData.CheckSSL),
|
Value(&m.siteFormData.CheckSSL),
|
||||||
huh.NewInput().Title("SSL Warning Threshold (days)").
|
huh.NewInput().Title("SSL Warning Threshold (days)").
|
||||||
Placeholder("7").
|
Placeholder("7").
|
||||||
Value(&m.siteFormData.Threshold),
|
Value(&m.siteFormData.Threshold).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 1 {
|
||||||
|
return fmt.Errorf("threshold must be at least 1 day")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
huh.NewInput().Title("Max Retries Before Alert").
|
huh.NewInput().Title("Max Retries Before Alert").
|
||||||
Placeholder("0").
|
Placeholder("0").
|
||||||
Value(&m.siteFormData.Retries),
|
Value(&m.siteFormData.Retries).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 0 {
|
||||||
|
return fmt.Errorf("retries cannot be negative")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
huh.NewConfirm().Title("Ignore TLS Errors?").
|
huh.NewConfirm().Title("Ignore TLS Errors?").
|
||||||
Value(&m.siteFormData.IgnoreTLS),
|
Value(&m.siteFormData.IgnoreTLS),
|
||||||
).Title("Advanced"),
|
).Title("Advanced").WithHideFunc(func() bool {
|
||||||
|
return m.siteFormData.SiteType == "group"
|
||||||
|
}),
|
||||||
).WithTheme(huh.ThemeDracula())
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
return m.huhForm.Init()
|
return m.huhForm.Init()
|
||||||
@@ -402,6 +524,7 @@ func (m *Model) submitSiteForm() {
|
|||||||
retries, _ := strconv.Atoi(d.Retries)
|
retries, _ := strconv.Atoi(d.Retries)
|
||||||
port, _ := strconv.Atoi(d.Port)
|
port, _ := strconv.Atoi(d.Port)
|
||||||
timeout, _ := strconv.Atoi(d.Timeout)
|
timeout, _ := strconv.Atoi(d.Timeout)
|
||||||
|
groupID, _ := strconv.Atoi(d.GroupID)
|
||||||
if interval < 1 {
|
if interval < 1 {
|
||||||
interval = 60
|
interval = 60
|
||||||
}
|
}
|
||||||
@@ -424,6 +547,7 @@ func (m *Model) submitSiteForm() {
|
|||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Description: d.Description,
|
Description: d.Description,
|
||||||
IgnoreTLS: d.IgnoreTLS,
|
IgnoreTLS: d.IgnoreTLS,
|
||||||
|
ParentID: groupID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.editID > 0 {
|
if m.editID > 0 {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func (m Model) viewUsersTab() string {
|
|||||||
for i := m.tableOffset; i < end; i++ {
|
for i := m.tableOffset; i < end; i++ {
|
||||||
u := m.users[i]
|
u := m.users[i]
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", u.ID),
|
fmt.Sprintf("%d", i+1),
|
||||||
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
|
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
|
||||||
fmtRole(u.Role),
|
fmtRole(u.Role),
|
||||||
fmtKey(u.PublicKey),
|
fmtKey(u.PublicKey),
|
||||||
@@ -80,7 +80,7 @@ func (m Model) viewUsersTab() string {
|
|||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderStyle(userBorderStyle).
|
BorderStyle(userBorderStyle).
|
||||||
Width(tableWidth).
|
Width(tableWidth).
|
||||||
Headers("ID", "USERNAME", "ROLE", "PUBLIC KEY").
|
Headers("#", "USERNAME", "ROLE", "PUBLIC KEY").
|
||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
if row == table.HeaderRow {
|
if row == table.HeaderRow {
|
||||||
|
|||||||
+105
-16
@@ -40,6 +40,7 @@ const (
|
|||||||
stateFormSite
|
stateFormSite
|
||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
|
stateConfirmDelete
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -62,6 +63,12 @@ type Model struct {
|
|||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
|
|
||||||
|
deleteID int
|
||||||
|
deleteName string
|
||||||
|
deleteTab int
|
||||||
|
|
||||||
|
collapsed map[int]bool
|
||||||
|
|
||||||
// harmonica animation state
|
// harmonica animation state
|
||||||
pulseSpring harmonica.Spring
|
pulseSpring harmonica.Spring
|
||||||
pulsePos float64
|
pulsePos float64
|
||||||
@@ -85,6 +92,7 @@ func InitialModel(isAdmin bool) Model {
|
|||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
zones: z,
|
zones: z,
|
||||||
pulseSpring: spring,
|
pulseSpring: spring,
|
||||||
|
collapsed: make(map[int]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +103,41 @@ func (m Model) Init() tea.Cmd {
|
|||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
if m.state == stateConfirmDelete {
|
||||||
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch keyMsg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
if store.Get() != nil {
|
||||||
|
switch m.deleteTab {
|
||||||
|
case 0:
|
||||||
|
store.Get().DeleteSite(m.deleteID)
|
||||||
|
monitor.RemoveSite(m.deleteID)
|
||||||
|
m.adjustCursor(len(m.sites) - 1)
|
||||||
|
case 1:
|
||||||
|
store.Get().DeleteAlert(m.deleteID)
|
||||||
|
m.adjustCursor(len(m.alerts) - 1)
|
||||||
|
case 3:
|
||||||
|
store.Get().DeleteUser(m.deleteID)
|
||||||
|
m.adjustCursor(len(m.users) - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.refreshData()
|
||||||
|
m.state = stateDashboard
|
||||||
|
if m.deleteTab == 3 {
|
||||||
|
m.state = stateUsers
|
||||||
|
}
|
||||||
|
case "n", "N", "esc":
|
||||||
|
m.state = stateDashboard
|
||||||
|
if m.deleteTab == 3 {
|
||||||
|
m.state = stateUsers
|
||||||
|
}
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
@@ -259,6 +302,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
}
|
}
|
||||||
|
case " ":
|
||||||
|
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||||
|
gid := m.sites[m.cursor].ID
|
||||||
|
m.collapsed[gid] = !m.collapsed[gid]
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
case "p":
|
case "p":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
site := m.sites[m.cursor]
|
site := m.sites[m.cursor]
|
||||||
@@ -270,19 +319,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshData()
|
m.refreshData()
|
||||||
}
|
}
|
||||||
case "d", "backspace":
|
case "d", "backspace":
|
||||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
store.Get().DeleteAlert(m.alerts[m.cursor].ID)
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
m.adjustCursor(len(m.alerts) - 1)
|
m.deleteName = m.sites[m.cursor].Name
|
||||||
} else if m.currentTab == 0 && len(m.sites) > 0 {
|
m.deleteTab = 0
|
||||||
id := m.sites[m.cursor].ID
|
m.state = stateConfirmDelete
|
||||||
store.Get().DeleteSite(id)
|
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||||
monitor.RemoveSite(id)
|
m.deleteID = m.alerts[m.cursor].ID
|
||||||
m.adjustCursor(len(m.sites) - 1)
|
m.deleteName = m.alerts[m.cursor].Name
|
||||||
|
m.deleteTab = 1
|
||||||
|
m.state = stateConfirmDelete
|
||||||
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
|
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
|
||||||
store.Get().DeleteUser(m.users[m.cursor].ID)
|
m.deleteID = m.users[m.cursor].ID
|
||||||
m.adjustCursor(len(m.users) - 1)
|
m.deleteName = m.users[m.cursor].Username
|
||||||
|
m.deleteTab = 3
|
||||||
|
m.state = stateConfirmDelete
|
||||||
}
|
}
|
||||||
m.refreshData()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,13 +430,40 @@ func (m *Model) adjustCursor(newLen int) {
|
|||||||
|
|
||||||
func (m *Model) refreshData() {
|
func (m *Model) refreshData() {
|
||||||
monitor.Mutex.RLock()
|
monitor.Mutex.RLock()
|
||||||
var sites []models.Site
|
var allSites []models.Site
|
||||||
for _, s := range monitor.LiveState {
|
for _, s := range monitor.LiveState {
|
||||||
sites = append(sites, s)
|
allSites = append(allSites, s)
|
||||||
}
|
}
|
||||||
monitor.Mutex.RUnlock()
|
monitor.Mutex.RUnlock()
|
||||||
sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID })
|
|
||||||
m.sites = sites
|
var groups, ungrouped []models.Site
|
||||||
|
children := make(map[int][]models.Site)
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.Type == "group" {
|
||||||
|
groups = append(groups, s)
|
||||||
|
} else if s.ParentID > 0 {
|
||||||
|
children[s.ParentID] = append(children[s.ParentID], s)
|
||||||
|
} else {
|
||||||
|
ungrouped = append(ungrouped, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
||||||
|
for pid := range children {
|
||||||
|
c := children[pid]
|
||||||
|
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
||||||
|
children[pid] = c
|
||||||
|
}
|
||||||
|
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
||||||
|
|
||||||
|
var ordered []models.Site
|
||||||
|
for _, g := range groups {
|
||||||
|
ordered = append(ordered, g)
|
||||||
|
if !m.collapsed[g.ID] {
|
||||||
|
ordered = append(ordered, children[g.ID]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered = append(ordered, ungrouped...)
|
||||||
|
m.sites = ordered
|
||||||
if store.Get() != nil {
|
if store.Get() != nil {
|
||||||
m.alerts = store.Get().GetAllAlerts()
|
m.alerts = store.Get().GetAllAlerts()
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
@@ -426,6 +505,16 @@ func (m Model) pulseIndicator() string {
|
|||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
switch m.state {
|
switch m.state {
|
||||||
|
case stateConfirmDelete:
|
||||||
|
kind := "monitor"
|
||||||
|
if m.deleteTab == 1 {
|
||||||
|
kind = "alert"
|
||||||
|
} else if m.deleteTab == 3 {
|
||||||
|
kind = "user"
|
||||||
|
}
|
||||||
|
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||||
|
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
||||||
|
return lipgloss.NewStyle().Padding(2, 4).Render(msg + "\n\n" + hint)
|
||||||
case stateFormSite, stateFormAlert, stateFormUser:
|
case stateFormSite, stateFormAlert, stateFormUser:
|
||||||
if m.huhForm != nil {
|
if m.huhForm != nil {
|
||||||
title := ""
|
title := ""
|
||||||
@@ -490,7 +579,7 @@ func (m Model) viewDashboard() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [p] Pause [Space] Collapse [Tab/Click] Switch [q] Quit")
|
||||||
if m.currentTab == 3 {
|
if m.currentTab == 3 {
|
||||||
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user