release: 2026.05.1 — distributed probing, config-as-code, TUI polish #15

Merged
lerko merged 47 commits from develop into main 2026-05-16 20:03:54 +00:00
12 changed files with 423 additions and 46 deletions
Showing only changes of commit 41a8a90bed - Show all commits
+1
View File
@@ -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{
+7 -1
View File
@@ -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"`
+30
View File
@@ -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) {
+39 -1
View File
@@ -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 == "" {
+9 -3
View File
@@ -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;
} }
+41 -1
View File
@@ -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{
+41 -2
View File
@@ -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{
+5 -1
View File
@@ -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
} }
+2 -2
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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")
} }