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
9 changed files with 253 additions and 24 deletions
Showing only changes of commit e97780ad38 - 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) {
+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
} }
+55 -5
View File
@@ -356,7 +356,17 @@ 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).
Validate(func(s string) error {
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("Alert Channel"). huh.NewSelect[string]().Title("Alert Channel").
Options(alertOpts...). Options(alertOpts...).
Value(&m.siteFormData.AlertID), Value(&m.siteFormData.AlertID),
@@ -369,10 +379,30 @@ 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 {
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),
@@ -382,10 +412,30 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
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"),
+64 -11
View File
@@ -40,6 +40,7 @@ const (
stateFormSite stateFormSite
stateFormAlert stateFormAlert
stateFormUser stateFormUser
stateConfirmDelete
) )
type Model struct { type Model struct {
@@ -62,6 +63,10 @@ type Model struct {
isAdmin bool isAdmin bool
zones *zone.Manager zones *zone.Manager
deleteID int
deleteName string
deleteTab int
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
pulsePos float64 pulsePos float64
@@ -95,6 +100,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 {
@@ -270,19 +310,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()
} }
} }
} }
@@ -426,6 +469,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 := ""