From e04cfcf433d7ca5f9cc1a3e2cee420834cb656da Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 30 Jul 2026 08:48:53 +0200 Subject: [PATCH] feat: Last Seen column tracking Alertmanager re-send heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alert list showed only Started, which comes from Prometheus and never changes for the lifetime of an alert instance. A firing alert that started 12 days ago looked identical whether Alertmanager refreshed it 30 seconds ago or went silent a week ago. terdut-server already tracks this: the webhook upsert sets received_at on every accepted payload, including the periodic re-sends issued at repeat_interval, and its archiver treats the field as a liveness heartbeat. The field was already decoded into api.Alert.ReceivedAt and simply never rendered. Add a Last Seen column to the alert tables, rendered with the existing humanAgo helper. The Alerts and Archived tabs share alertColumns/alertRows, so both pick it up. The width budget is re-derived for five columns; the slack constant now accounts for all of bubbles' per-cell padding, so the table lands exactly on the terminal width instead of overflowing by two columns as it did with four. The detail view gains a matching Last Seen line, with the timeline labels widened to keep values aligned. Since received_at stops advancing once an alert resolves, also pull through the server's resolution_source and show it in the status header (RESOLVED · alertmanager vs RESOLVED · expiry) so a frozen timestamp is explained. --- CLAUDE.md | 13 +++++++++++++ internal/api/types.go | 5 +++++ internal/tui/model.go | 13 ++++++++----- internal/tui/view.go | 14 ++++++++++---- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 226c027..05bc779 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,3 +66,16 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . | 3 | Alert detail: acknowledge, comment, statistics charts | | 4 | On-call schedule calendar view | | 5 | User management and API key lifecycle | + + +## Memory (GrayMatter) + +This project has persistent agent memory via the `graymatter` MCP tools: + +- `memory_search` (`agent_id`, `query`) — call at the **start of a task** when prior context might matter. +- `memory_add` (`agent_id`, `text`) — call whenever you learn something **durable**: user preferences, decisions, conventions, gotchas. +- `memory_reflect` (`action`, `agent`, `text`/`target`) — update or forget stale facts. ⚠ takes `agent`, not `agent_id`. +- `checkpoint_save` / `checkpoint_resume` (`agent_id`) — snapshot/restore session state before major refactors or across restarts. + +Use a stable `agent_id` of the form `-` (e.g. `myapp-backend`). Store conclusions, not conversation logs. Err on the side of remembering. + diff --git a/internal/api/types.go b/internal/api/types.go index 404c0d9..7b4cb6b 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -17,6 +17,11 @@ type Alert struct { AcknowledgedBy string `json:"acknowledged_by"` AcknowledgedAt *time.Time `json:"acknowledged_at"` ArchivedAt *time.Time `json:"archived_at,omitempty"` + + // ResolutionSource records why a resolved alert left the firing state: + // "alertmanager" for a real resolved webhook, "expiry" when the server + // inferred it after the alert stopped being refreshed. + ResolutionSource *string `json:"resolution_source,omitempty"` } type AlertStats struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index cc2c94c..f0f8148 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -340,18 +340,21 @@ func (m Model) detailViewportHeight() int { // ── Column definitions ───────────────────────────────────────────────────── func alertColumns(width int) []table.Column { - nameW := width/2 - 8 + const statusW, timeW = 10, 12 + nameW := width/2 - 14 if nameW < 20 { nameW = 20 } - ackW := width - nameW - 10 - 12 - 6 + // 10 = bubbles' Padding(0, 1) on each of the five cells. + ackW := width - nameW - statusW - 2*timeW - 10 if ackW < 8 { ackW = 8 } return []table.Column{ {Title: "Name", Width: nameW}, - {Title: "Status", Width: 10}, - {Title: "Started", Width: 12}, + {Title: "Status", Width: statusW}, + {Title: "Started", Width: timeW}, + {Title: "Last Seen", Width: timeW}, {Title: "Ack By", Width: ackW}, } } @@ -400,7 +403,7 @@ func alertRows(alerts []api.Alert) []table.Row { now := time.Now() rows := make([]table.Row, len(alerts)) for i, a := range alerts { - rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), a.AcknowledgedBy} + rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), a.AcknowledgedBy} } return rows } diff --git a/internal/tui/view.go b/internal/tui/view.go index a0e1668..60671b6 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -381,7 +381,11 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i if alert.Status == "firing" { statusStr = styleFiring.Render("● FIRING") } else { - statusStr = styleResolved.Render("✓ RESOLVED") + label := "✓ RESOLVED" + if alert.ResolutionSource != nil { + label += " · " + *alert.ResolutionSource + } + statusStr = styleResolved.Render(label) } nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) if nameGap < 1 { @@ -390,17 +394,19 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n") // Timeline - b.WriteString(fmt.Sprintf(" Started: %s (%s)\n", + b.WriteString(fmt.Sprintf(" Started: %s (%s)\n", alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt))) + b.WriteString(fmt.Sprintf(" Last Seen: %s (%s)\n", + alert.ReceivedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.ReceivedAt))) if alert.EndsAt != nil { - b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC"))) + b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC"))) } if alert.GeneratorURL != "" { url := alert.GeneratorURL if len(url) > contentW-12 { url = url[:contentW-15] + "…" } - b.WriteString(fmt.Sprintf(" Source: %s\n", url)) + b.WriteString(fmt.Sprintf(" Source: %s\n", url)) } b.WriteString("\n")