feat: Last Seen column tracking Alertmanager re-send heartbeat
Release / build (amd64, darwin) (push) Failing after 10s
Release / build (amd64, linux) (push) Failing after 9s
Release / build (arm64, darwin) (push) Failing after 11s
Release / build (arm64, linux) (push) Failing after 9s
Release / release (push) Has been skipped

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.
This commit is contained in:
Niklas Ye
2026-07-30 08:48:53 +02:00
parent 6834302622
commit e04cfcf433
4 changed files with 36 additions and 9 deletions
+10 -4
View File
@@ -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")