From 01dd53241a5b20b5f42c7f6cff76083bea1f3ee4 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 20 Jun 2026 20:04:08 -0400 Subject: [PATCH] refactor: separate log timestamp from message as structured LogEntry Introduced models.LogEntry{Message, CreatedAt} to replace raw strings in the log pipeline. Timestamps are now formatted at render time, not baked into stored messages. - Engine: appendLog stores LogEntry with time.Now() - Store: LoadLogs returns []LogEntry, selects created_at from DB - Store: strips legacy [HH:MM] prefix from pre-refactor DB entries - TUI: sidebar shows "MM/DD HH:MM" from CreatedAt - TUI: full log view shows "MM/DD HH:MM" from CreatedAt - SaveLog still receives plain message string (DB handles timestamp) --- internal/models/models.go | 5 ++++ internal/monitor/monitor.go | 24 ++++++++++--------- internal/monitor/monitor_test.go | 15 +++++++----- internal/store/sqlstore.go | 23 +++++++++++------- internal/store/sqlstore_test.go | 2 +- internal/store/store.go | 2 +- internal/store/storetest/mock.go | 4 ++-- internal/tui/tab_logs.go | 37 ++++++++++------------------- internal/tui/tab_logs_sidebar.go | 40 +++++++++++--------------------- internal/tui/update_test.go | 2 +- 10 files changed, 73 insertions(+), 81 deletions(-) diff --git a/internal/models/models.go b/internal/models/models.go index b8983a4..319cbc1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -86,6 +86,11 @@ type ProbeNode struct { Version string } +type LogEntry struct { + Message string + CreatedAt time.Time +} + // AlertHealthRecord is the persisted send health of an alert channel. It lets the // "last sent" / health indicators survive restarts instead of resetting to "never". type AlertHealthRecord struct { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 779667c..929f99e 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -40,7 +40,7 @@ type Engine struct { liveState map[int]models.Site logMu sync.RWMutex - logStore []string + logStore []models.LogEntry activeMu sync.RWMutex isActive bool @@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string { // appendLog adds a timestamped entry to the in-memory ring buffer and returns // it. It never touches the database, so it is safe to call from the db-write // drop/error path without recursing back through the write queue. -func (e *Engine) appendLog(msg string) string { - ts := time.Now().Format("15:04:05") - entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg)) +func (e *Engine) appendLog(msg string) models.LogEntry { + entry := models.LogEntry{ + Message: sanitizeLog(msg), + CreatedAt: time.Now(), + } e.logMu.Lock() - e.logStore = append([]string{entry}, e.logStore...) + e.logStore = append([]models.LogEntry{entry}, e.logStore...) if len(e.logStore) > maxLogEntries { e.logStore = e.logStore[:maxLogEntries] } @@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string { func (e *Engine) AddLog(msg string) { entry := e.appendLog(msg) - e.enqueueWrite(writeLog{message: entry}) + e.enqueueWrite(writeLog{message: entry.Message}) } // enqueueWrite hands a persistence task to the writer goroutine without @@ -246,16 +248,16 @@ func (e *Engine) Stop() { } func (e *Engine) InitLogs() { - logs, err := e.db.LoadLogs(context.Background(), maxLogEntries) + entries, err := e.db.LoadLogs(context.Background(), maxLogEntries) if err != nil { return } - if len(logs) == 0 { + if len(entries) == 0 { return } e.logMu.Lock() defer e.logMu.Unlock() - e.logStore = logs + e.logStore = entries } // InitAlertHealth restores persisted alert send health so the dashboard shows real @@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() { } } -func (e *Engine) GetLogs() []string { +func (e *Engine) GetLogs() []models.LogEntry { e.logMu.RLock() defer e.logMu.RUnlock() - logs := make([]string, len(e.logStore)) + logs := make([]models.LogEntry, len(e.logStore)) copy(logs, e.logStore) return logs } diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 45b8925..8ebd0c6 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -25,7 +25,7 @@ type mockStore struct { sites []models.SiteConfig alerts map[int]models.AlertConfig maintenance map[int]bool - logs []string + logs []models.LogEntry history map[int][]models.CheckRecord savedChecks []savedCheck savedLogs []string @@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error { return nil } -func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { +func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return m.logs, nil } @@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) { logs := e.GetLogs() found := false for _, l := range logs { - if containsStr(l, "suppressed") { + if containsStr(l.Message, "suppressed") { found = true break } @@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) { if len(logs) != 100 { t.Errorf("expected 100 logs, got %d", len(logs)) } - if !containsStr(logs[0], "log-104") { - t.Errorf("expected newest log first, got %s", logs[0]) + if !containsStr(logs[0].Message, "log-104") { + t.Errorf("expected newest log first, got %s", logs[0].Message) } } func TestInitLogs_LoadsFromDB(t *testing.T) { ms := newMockStore() - ms.logs = []string{"old-log-1", "old-log-2"} + ms.logs = []models.LogEntry{ + {Message: "old-log-1"}, + {Message: "old-log-2"}, + } e := newTestEngine(ms) e.InitLogs() diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go index 0440bee..b6a96cb 100644 --- a/internal/store/sqlstore.go +++ b/internal/store/sqlstore.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "time" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" @@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error { return err } -func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) { - rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) +func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) { + rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit) if err != nil { return nil, err } defer rows.Close() - var logs []string + var entries []models.LogEntry for rows.Next() { - var msg string - if err := rows.Scan(&msg); err != nil { - return logs, err + var e models.LogEntry + if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil { + return entries, err } - logs = append(logs, msg) + // Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries. + if len(e.Message) > 3 && e.Message[0] == '[' { + if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 { + e.Message = strings.TrimSpace(e.Message[idx+1:]) + } + } + entries = append(entries, e) } - return logs, rows.Err() + return entries, rows.Err() } func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go index 01dbffe..85467c3 100644 --- a/internal/store/sqlstore_test.go +++ b/internal/store/sqlstore_test.go @@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) { // LoadLogs ordering ties when rows share a created_at second). present := make(map[string]bool, len(logs)) for _, l := range logs { - present[l] = true + present[l.Message] = true } if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] { t.Error("newest log was pruned") diff --git a/internal/store/store.go b/internal/store/store.go index 53033bb..861b3dc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -61,7 +61,7 @@ type Store interface { // Logs SaveLog(ctx context.Context, message string) error - LoadLogs(ctx context.Context, limit int) ([]string, error) + LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) PruneLogs(ctx context.Context) error // Maintenance Windows diff --git a/internal/store/storetest/mock.go b/internal/store/storetest/mock.go index 04b04bc..759fbd2 100644 --- a/internal/store/storetest/mock.go +++ b/internal/store/storetest/mock.go @@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error { return nil } -func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } -func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } +func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil } +func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { if m.GetActiveMaintenanceWindowsFunc != nil { diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 29463ea..4d1ed8e 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "strings" + + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" ) type logSeverity int @@ -15,8 +17,8 @@ const ( severitySystem ) -func classifyLog(line string) logSeverity { - lower := strings.ToLower(line) +func classifyLog(msg string) logSeverity { + lower := strings.ToLower(msg) switch { case strings.Contains(lower, "confirmed down"), strings.Contains(lower, "is down"), @@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string { } } -func (m Model) renderLogLine(line string) string { - sev := classifyLog(line) +func (m Model) renderLogLine(entry models.LogEntry) string { + sev := classifyLog(entry.Message) tag := m.renderLogTag(sev) - - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = m.st.subtleStyle.Render(line[1:idx]) - msg = strings.TrimSpace(line[idx+1:]) - } - } - - if ts != "" { - return fmt.Sprintf(" %s %s %s", ts, tag, msg) - } - return fmt.Sprintf(" %s %s", tag, msg) + ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04")) + return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message) } -// refreshLogContent rebuilds the log viewport from the full engine log list, -// filtering before windowing so the entry count and "(n hidden)" reflect all -// logs, not just the visible viewport slice. func (m *Model) refreshLogContent() { var rendered []string total := 0 shown := 0 - for _, line := range m.engine.GetLogs() { - if strings.TrimSpace(line) == "" { + for _, entry := range m.engine.GetLogs() { + if strings.TrimSpace(entry.Message) == "" { continue } total++ - sev := classifyLog(line) + sev := classifyLog(entry.Message) if m.logFilterImportant && !isImportantLog(sev) { continue } shown++ - rendered = append(rendered, m.renderLogLine(line)) + rendered = append(rendered, m.renderLogLine(entry)) } m.logTotal = total diff --git a/internal/tui/tab_logs_sidebar.go b/internal/tui/tab_logs_sidebar.go index ca5949c..05b4e8e 100644 --- a/internal/tui/tab_logs_sidebar.go +++ b/internal/tui/tab_logs_sidebar.go @@ -3,11 +3,12 @@ package tui import ( "strings" + "gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "github.com/charmbracelet/lipgloss" ) -func (m Model) renderCompactLogLine(line string, maxW int) string { - sev := classifyLog(line) +func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string { + sev := classifyLog(entry.Message) var tag string switch sev { @@ -23,33 +24,20 @@ func (m Model) renderCompactLogLine(line string, maxW int) string { tag = m.st.subtleStyle.Render("·") } - ts := "" - msg := line - if len(line) > 10 && line[0] == '[' { - if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { - ts = line[1:idx] - msg = strings.TrimSpace(line[idx+1:]) - } - } + ts := entry.CreatedAt.Local().Format("01/02 15:04") + msg := entry.Message msg = strings.TrimPrefix(msg, "Monitor ") msg = strings.TrimPrefix(msg, "Push ") - // prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp - prefixW := 3 - if ts != "" { - prefixW = len(ts) + 4 - } + prefixW := len(ts) + 4 msgW := maxW - prefixW if msgW < 5 { msgW = 5 } msg = limitStr(msg, msgW) - if ts != "" { - return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg - } - return " " + tag + " " + msg + return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg } func (m Model) viewLogsSidebar(width, maxLines int) string { @@ -61,14 +49,14 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) var all []string - for _, line := range logs { - if strings.TrimSpace(line) == "" { + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { continue } - if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { continue } - all = append(all, m.renderCompactLogLine(line, width)) + all = append(all, m.renderCompactLogLine(entry, width)) } start := m.logScrollOffset @@ -87,11 +75,11 @@ func (m Model) viewLogsSidebar(width, maxLines int) string { func (m *Model) scrollLogs(delta int) { logs := m.engine.GetLogs() total := 0 - for _, line := range logs { - if strings.TrimSpace(line) == "" { + for _, entry := range logs { + if strings.TrimSpace(entry.Message) == "" { continue } - if m.logFilterImportant && !isImportantLog(classifyLog(line)) { + if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) { continue } total++ diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 1a44013..11b98f6 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) { mm := updated.(Model) found := false for _, line := range mm.engine.GetLogs() { - if strings.Contains(line, "Delete site failed: boom") { + if strings.Contains(line.Message, "Delete site failed: boom") { found = true } }