fix/feat: UX polish, security fixes, groups #2
@@ -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)
|
||||
}
|
||||
|
||||
monitor.InitHistoryFromStore()
|
||||
monitor.StartEngine()
|
||||
|
||||
server.Start(server.ServerConfig{
|
||||
|
||||
@@ -50,7 +50,13 @@ type User struct {
|
||||
Role string
|
||||
}
|
||||
|
||||
// Phase 5: Backup Structure
|
||||
type CheckRecord struct {
|
||||
SiteID int
|
||||
LatencyNs int64
|
||||
IsUp bool
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
type Backup struct {
|
||||
Sites []Site `json:"sites"`
|
||||
Alerts []AlertConfig `json:"alerts"`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"go-upkeep/internal/store"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -19,6 +20,31 @@ var (
|
||||
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) {
|
||||
historyMu.Lock()
|
||||
defer historyMu.Unlock()
|
||||
@@ -43,6 +69,10 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) {
|
||||
if 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) {
|
||||
|
||||
@@ -185,6 +185,12 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
||||
<script>
|
||||
var lastUpdate = null;
|
||||
|
||||
function esc(s) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(s));
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function cssClass(status) {
|
||||
return status.replace(/\s+/g, '-');
|
||||
}
|
||||
@@ -234,13 +240,13 @@ func renderStatusPage(w http.ResponseWriter, title string) {
|
||||
var s = sites[i];
|
||||
var st = s.Paused ? 'PAUSED' : s.Status;
|
||||
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}) : '—';
|
||||
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" 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;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ func (p *PostgresStore) Init() error {
|
||||
public_key TEXT NOT NULL,
|
||||
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 {
|
||||
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{
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
|
||||
"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
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
return models.Backup{
|
||||
|
||||
@@ -57,7 +57,15 @@ func (s *SQLiteStore) Init() error {
|
||||
username TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -204,7 +212,38 @@ func (s *SQLiteStore) DeleteUser(id int) error {
|
||||
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 {
|
||||
return models.Backup{
|
||||
|
||||
@@ -27,7 +27,11 @@ type Store interface {
|
||||
UpdateUser(id int, username, publicKey, role string) 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
|
||||
ImportData(data models.Backup) error
|
||||
}
|
||||
|
||||
@@ -356,7 +356,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
}),
|
||||
huh.NewInput().Title("Check Interval (seconds)").
|
||||
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").
|
||||
Options(alertOpts...).
|
||||
Value(&m.siteFormData.AlertID),
|
||||
@@ -369,10 +379,30 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
huh.NewInput().Title("Port").
|
||||
Placeholder("0").
|
||||
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)").
|
||||
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").
|
||||
Placeholder("Optional description").
|
||||
Value(&m.siteFormData.Description),
|
||||
@@ -382,10 +412,30 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
||||
Value(&m.siteFormData.CheckSSL),
|
||||
huh.NewInput().Title("SSL Warning Threshold (days)").
|
||||
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").
|
||||
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?").
|
||||
Value(&m.siteFormData.IgnoreTLS),
|
||||
).Title("Advanced"),
|
||||
|
||||
+64
-11
@@ -40,6 +40,7 @@ const (
|
||||
stateFormSite
|
||||
stateFormAlert
|
||||
stateFormUser
|
||||
stateConfirmDelete
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
@@ -62,6 +63,10 @@ type Model struct {
|
||||
isAdmin bool
|
||||
zones *zone.Manager
|
||||
|
||||
deleteID int
|
||||
deleteName string
|
||||
deleteTab int
|
||||
|
||||
// harmonica animation state
|
||||
pulseSpring harmonica.Spring
|
||||
pulsePos float64
|
||||
@@ -95,6 +100,41 @@ func (m Model) Init() tea.Cmd {
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, 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.)
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||
@@ -270,19 +310,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.refreshData()
|
||||
}
|
||||
case "d", "backspace":
|
||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
store.Get().DeleteAlert(m.alerts[m.cursor].ID)
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
} else if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
id := m.sites[m.cursor].ID
|
||||
store.Get().DeleteSite(id)
|
||||
monitor.RemoveSite(id)
|
||||
m.adjustCursor(len(m.sites) - 1)
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.deleteID = m.sites[m.cursor].ID
|
||||
m.deleteName = m.sites[m.cursor].Name
|
||||
m.deleteTab = 0
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
m.deleteID = m.alerts[m.cursor].ID
|
||||
m.deleteName = m.alerts[m.cursor].Name
|
||||
m.deleteTab = 1
|
||||
m.state = stateConfirmDelete
|
||||
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
|
||||
store.Get().DeleteUser(m.users[m.cursor].ID)
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
m.deleteID = m.users[m.cursor].ID
|
||||
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 {
|
||||
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:
|
||||
if m.huhForm != nil {
|
||||
title := ""
|
||||
|
||||
Reference in New Issue
Block a user