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)
This commit is contained in:
@@ -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 {
|
||||
|
||||
+13
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -212,7 +212,7 @@ 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) 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) {
|
||||
|
||||
+12
-25
@@ -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:])
|
||||
}
|
||||
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
|
||||
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
||||
}
|
||||
|
||||
if ts != "" {
|
||||
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
|
||||
}
|
||||
return fmt.Sprintf(" %s %s", tag, msg)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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,34 +24,21 @@ 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
|
||||
}
|
||||
|
||||
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||
logs := m.engine.GetLogs()
|
||||
@@ -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++
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user