3 Commits

Author SHA1 Message Date
Niklas Ye e04cfcf433 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.
2026-07-30 08:48:53 +02:00
Niklas Ye 6834302622 feat: Archived alerts tab with archive/unarchive actions
Release / build (amd64, linux) (push) Failing after 6s
Release / release (push) Has been skipped
Release / build (amd64, darwin) (push) Failing after 5s
Release / build (arm64, darwin) (push) Failing after 6s
Release / build (arm64, linux) (push) Failing after 11s
Add a fourth tab (Alerts | Archived | Schedule | Users).
Archived alerts are fetched lazily on first visit using the
archived=true query param on GET /api/alerts.

Press x from the Alerts list or detail to archive an alert;
the non-archived list refreshes immediately. Press x from the
Archived list or detail to unarchive; the archived list
refreshes. Ack/unack are disabled in the Archived detail view.

New API methods: ArchiveAlert (POST), UnarchiveAlert (DELETE).
ArchivedAt field added to the Alert type.
2026-05-22 13:45:30 +02:00
Niklas Ye 24c2e6003a ci: GitHub Actions release workflow and Makefile 2026-05-22 11:59:21 +02:00
8 changed files with 308 additions and 17 deletions
+57
View File
@@ -0,0 +1,57 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build \
-ldflags "-X main.version=${{ github.ref_name }}" \
-o terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} \
.
- uses: actions/upload-artifact@v4
with:
name: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: 'terdut-tui-*'
+13
View File
@@ -66,3 +66,16 @@ go build -ldflags="-X main.version=v0.1.0" -o terdut-tui .
| 3 | Alert detail: acknowledge, comment, statistics charts | | 3 | Alert detail: acknowledge, comment, statistics charts |
| 4 | On-call schedule calendar view | | 4 | On-call schedule calendar view |
| 5 | User management and API key lifecycle | | 5 | User management and API key lifecycle |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## 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 `<project>-<role>` (e.g. `myapp-backend`). Store conclusions, not conversation logs. Err on the side of remembering.
<!-- graymatter:instructions:end -->
+12
View File
@@ -0,0 +1,12 @@
VERSION := $(shell git describe --tags --always --dirty)
.PHONY: build install test
build:
go build -ldflags "-X main.version=$(VERSION)" -o terdut-tui .
install:
go install -ldflags "-X main.version=$(VERSION)" .
test:
go test ./...
+23 -2
View File
@@ -61,12 +61,16 @@ func (c *Client) do(req *http.Request, out any) error {
return nil return nil
} }
// ListAlerts fetches alerts from the server. status may be "firing", "resolved", or "" for all. // ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all.
func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) { // Set archived=true to fetch only archived alerts; false returns only non-archived.
func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) {
q := url.Values{} q := url.Values{}
if status != "" { if status != "" {
q.Set("status", status) q.Set("status", status)
} }
if archived {
q.Set("archived", "true")
}
if limit > 0 { if limit > 0 {
q.Set("limit", strconv.Itoa(limit)) q.Set("limit", strconv.Itoa(limit))
} }
@@ -83,6 +87,23 @@ func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) {
return alerts, c.do(req, &alerts) return alerts, c.do(req, &alerts)
} }
func (c *Client) ArchiveAlert(id int64) (*Alert, error) {
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/alerts/%d/archive", id))
if err != nil {
return nil, err
}
var alert Alert
return &alert, c.do(req, &alert)
}
func (c *Client) UnarchiveAlert(id int64) error {
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/archive", id))
if err != nil {
return err
}
return c.do(req, nil)
}
// GetAlertStats fetches aggregate alert counts. // GetAlertStats fetches aggregate alert counts.
func (c *Client) GetAlertStats() (*AlertStats, error) { func (c *Client) GetAlertStats() (*AlertStats, error) {
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts") req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
+6
View File
@@ -16,6 +16,12 @@ type Alert struct {
AcknowledgedByID *int64 `json:"acknowledged_by_id"` AcknowledgedByID *int64 `json:"acknowledged_by_id"`
AcknowledgedBy string `json:"acknowledged_by"` AcknowledgedBy string `json:"acknowledged_by"`
AcknowledgedAt *time.Time `json:"acknowledged_at"` 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 { type AlertStats struct {
+69 -7
View File
@@ -18,7 +18,8 @@ import (
type section int type section int
const ( const (
sectionAlerts section = iota sectionAlerts section = iota
sectionArchived
sectionSchedule sectionSchedule
sectionUsers sectionUsers
) )
@@ -53,6 +54,9 @@ const (
type connectedMsg struct{} type connectedMsg struct{}
type connectErrMsg struct{ err error } type connectErrMsg struct{ err error }
type alertsFetchedMsg struct{ alerts []api.Alert } type alertsFetchedMsg struct{ alerts []api.Alert }
type archivedAlertsFetchedMsg struct{ alerts []api.Alert }
type alertArchivedMsg struct{ alerts []api.Alert }
type alertUnarchivedMsg struct{ alerts []api.Alert }
type statsFetchedMsg struct{ stats api.AlertStats } type statsFetchedMsg struct{ stats api.AlertStats }
type fetchDataErrMsg struct{ err error } type fetchDataErrMsg struct{ err error }
type tickMsg time.Time type tickMsg time.Time
@@ -113,6 +117,11 @@ type Model struct {
filterStatus string filterStatus string
alertTable table.Model alertTable table.Model
// Archived alerts
archivedAlerts []api.Alert
archivedLoading bool
archivedTable table.Model
// Detail // Detail
selectedAlert api.Alert selectedAlert api.Alert
comments []api.Comment comments []api.Comment
@@ -168,6 +177,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
alertT := table.New(table.WithFocused(true)) alertT := table.New(table.WithFocused(true))
alertT.SetStyles(ts) alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true))
archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true)) schedT := table.New(table.WithFocused(true))
schedT.SetStyles(ts) schedT.SetStyles(ts)
@@ -215,6 +227,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
filterStatus: "firing", filterStatus: "firing",
commentCursor: -1, commentCursor: -1,
alertTable: alertT, alertTable: alertT,
archivedTable: archivedT,
commentInput: ti, commentInput: ti,
scheduleWindow: window, scheduleWindow: window,
scheduleTable: schedT, scheduleTable: schedT,
@@ -254,6 +267,16 @@ func (m *Model) rebuildTable() {
m.alertTable.SetHeight(h) m.alertTable.SetHeight(h)
} }
func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(alertColumns(m.width))
m.archivedTable.SetRows(alertRows(m.archivedAlerts))
h := m.height - 8
if h < 1 {
h = 1
}
m.archivedTable.SetHeight(h)
}
func (m *Model) rebuildScheduleTable() { func (m *Model) rebuildScheduleTable() {
m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetColumns(scheduleColumns(m.width))
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) m.scheduleTable.SetRows(scheduleRows(m.scheduleDays))
@@ -317,18 +340,21 @@ func (m Model) detailViewportHeight() int {
// ── Column definitions ───────────────────────────────────────────────────── // ── Column definitions ─────────────────────────────────────────────────────
func alertColumns(width int) []table.Column { func alertColumns(width int) []table.Column {
nameW := width/2 - 8 const statusW, timeW = 10, 12
nameW := width/2 - 14
if nameW < 20 { if nameW < 20 {
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 { if ackW < 8 {
ackW = 8 ackW = 8
} }
return []table.Column{ return []table.Column{
{Title: "Name", Width: nameW}, {Title: "Name", Width: nameW},
{Title: "Status", Width: 10}, {Title: "Status", Width: statusW},
{Title: "Started", Width: 12}, {Title: "Started", Width: timeW},
{Title: "Last Seen", Width: timeW},
{Title: "Ack By", Width: ackW}, {Title: "Ack By", Width: ackW},
} }
} }
@@ -377,7 +403,7 @@ func alertRows(alerts []api.Alert) []table.Row {
now := time.Now() now := time.Now()
rows := make([]table.Row, len(alerts)) rows := make([]table.Row, len(alerts))
for i, a := range 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 return rows
} }
@@ -466,7 +492,7 @@ func connectCmd(client *api.Client) tea.Cmd {
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd { func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
alerts, err := client.ListAlerts(status, 500) alerts, err := client.ListAlerts(status, false, 500)
if err != nil { if err != nil {
return fetchDataErrMsg{err} return fetchDataErrMsg{err}
} }
@@ -474,6 +500,42 @@ func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
} }
} }
func fetchArchivedAlertsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
alerts, err := client.ListAlerts("", true, 500)
if err != nil {
return fetchDataErrMsg{err}
}
return archivedAlertsFetchedMsg{alerts}
}
}
func archiveAlertCmd(client *api.Client, alertID int64, filterStatus string) tea.Cmd {
return func() tea.Msg {
if _, err := client.ArchiveAlert(alertID); err != nil {
return actionErrMsg{err}
}
alerts, err := client.ListAlerts(filterStatus, false, 500)
if err != nil {
return actionErrMsg{err}
}
return alertArchivedMsg{alerts}
}
}
func unarchiveAlertCmd(client *api.Client, alertID int64) tea.Cmd {
return func() tea.Msg {
if err := client.UnarchiveAlert(alertID); err != nil {
return actionErrMsg{err}
}
alerts, err := client.ListAlerts("", true, 500)
if err != nil {
return actionErrMsg{err}
}
return alertUnarchivedMsg{alerts}
}
}
func fetchStatsCmd(client *api.Client) tea.Cmd { func fetchStatsCmd(client *api.Client) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
stats, err := client.GetAlertStats() stats, err := client.GetAlertStats()
+81 -2
View File
@@ -16,6 +16,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.rebuildTable() m.rebuildTable()
m.rebuildArchivedTable()
m.rebuildScheduleTable() m.rebuildScheduleTable()
m.rebuildUserPickerTable() m.rebuildUserPickerTable()
m.rebuildUserManageTable() m.rebuildUserManageTable()
@@ -49,6 +50,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildTable() m.rebuildTable()
return m, nil return m, nil
case archivedAlertsFetchedMsg:
m.archivedAlerts = msg.alerts
m.archivedLoading = false
m.rebuildArchivedTable()
return m, nil
case alertArchivedMsg:
m.alerts = msg.alerts
m.loading = false
m.rebuildTable()
m.mode = modeDashboard
m.statusMsg = "Alert archived"
return m, clearStatusCmd()
case alertUnarchivedMsg:
m.archivedAlerts = msg.alerts
m.archivedLoading = false
m.rebuildArchivedTable()
m.mode = modeDashboard
m.statusMsg = "Alert unarchived"
return m, clearStatusCmd()
case statsFetchedMsg: case statsFetchedMsg:
m.stats = &msg.stats m.stats = &msg.stats
return m, nil return m, nil
@@ -215,6 +238,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m.alertTable, tableCmd = m.alertTable.Update(msg) m.alertTable, tableCmd = m.alertTable.Update(msg)
m2, ourCmd := m.handleKey(msg) m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd) return m2, tea.Batch(tableCmd, ourCmd)
case sectionArchived:
var tableCmd tea.Cmd
m.archivedTable, tableCmd = m.archivedTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case sectionSchedule: case sectionSchedule:
var tableCmd tea.Cmd var tableCmd tea.Cmd
m.scheduleTable, tableCmd = m.scheduleTable.Update(msg) m.scheduleTable, tableCmd = m.scheduleTable.Update(msg)
@@ -266,8 +294,12 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
case "tab": case "tab":
next := section((int(m.activeSection) + 1) % 3) next := section((int(m.activeSection) + 1) % 4)
m.activeSection = next m.activeSection = next
if next == sectionArchived && len(m.archivedAlerts) == 0 {
m.archivedLoading = true
return m, fetchArchivedAlertsCmd(m.client)
}
if next == sectionSchedule && len(m.scheduleDays) == 0 { if next == sectionSchedule && len(m.scheduleDays) == 0 {
m.scheduleLoading = true m.scheduleLoading = true
from := m.scheduleWindow from := m.scheduleWindow
@@ -284,6 +316,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if !m.connected { if !m.connected {
return m, connectCmd(m.client) return m, connectCmd(m.client)
} }
if m.activeSection == sectionArchived {
m.archivedLoading = true
return m, fetchArchivedAlertsCmd(m.client)
}
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
m.scheduleLoading = true m.scheduleLoading = true
from := m.scheduleWindow from := m.scheduleWindow
@@ -319,7 +355,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
case "enter": case "enter":
if m.activeSection == sectionAlerts && len(m.alerts) > 0 { if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
cursor := m.alertTable.Cursor() cursor := m.alertTable.Cursor()
if cursor < len(m.alerts) { if cursor >= 0 && cursor < len(m.alerts) {
m.selectedAlert = m.alerts[cursor] m.selectedAlert = m.alerts[cursor]
m.mode = modeDetail m.mode = modeDetail
m.commentCursor = -1 m.commentCursor = -1
@@ -328,6 +364,34 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID) return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
} }
} }
if m.activeSection == sectionArchived && len(m.archivedAlerts) > 0 {
cursor := m.archivedTable.Cursor()
if cursor >= 0 && cursor < len(m.archivedAlerts) {
m.selectedAlert = m.archivedAlerts[cursor]
m.mode = modeDetail
m.commentCursor = -1
m.detailLoading = true
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
}
}
return m, nil
case "x":
switch m.activeSection {
case sectionAlerts:
cursor := m.alertTable.Cursor()
if cursor < 0 || cursor >= len(m.alerts) {
return m, nil
}
return m, archiveAlertCmd(m.client, m.alerts[cursor].ID, m.filterStatus)
case sectionArchived:
cursor := m.archivedTable.Cursor()
if cursor < 0 || cursor >= len(m.archivedAlerts) {
return m, nil
}
return m, unarchiveAlertCmd(m.client, m.archivedAlerts[cursor].ID)
}
return m, nil return m, nil
// Schedule-specific keys // Schedule-specific keys
@@ -447,6 +511,9 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
case "a": case "a":
if m.activeSection != sectionAlerts {
return m, nil
}
if m.selectedAlert.AcknowledgedByID != nil { if m.selectedAlert.AcknowledgedByID != nil {
m.statusMsg = "already acknowledged" m.statusMsg = "already acknowledged"
return m, clearStatusCmd() return m, clearStatusCmd()
@@ -454,12 +521,24 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, acknowledgeCmd(m.client, m.selectedAlert.ID) return m, acknowledgeCmd(m.client, m.selectedAlert.ID)
case "A": case "A":
if m.activeSection != sectionAlerts {
return m, nil
}
if m.selectedAlert.AcknowledgedByID == nil { if m.selectedAlert.AcknowledgedByID == nil {
m.statusMsg = "not acknowledged" m.statusMsg = "not acknowledged"
return m, clearStatusCmd() return m, clearStatusCmd()
} }
return m, unacknowledgeCmd(m.client, m.selectedAlert.ID) return m, unacknowledgeCmd(m.client, m.selectedAlert.ID)
case "x":
if m.activeSection == sectionAlerts {
return m, archiveAlertCmd(m.client, m.selectedAlert.ID, m.filterStatus)
}
if m.activeSection == sectionArchived {
return m, unarchiveAlertCmd(m.client, m.selectedAlert.ID)
}
return m, nil
case "c": case "c":
m.mode = modeComment m.mode = modeComment
m.commentInput.Reset() m.commentInput.Reset()
+47 -6
View File
@@ -10,7 +10,7 @@ import (
"github.com/yeniklas/terdut-tui/internal/api" "github.com/yeniklas/terdut-tui/internal/api"
) )
var sectionNames = []string{"Alerts", "Schedule", "Users"} var sectionNames = []string{"Alerts", "Archived", "Schedule", "Users"}
func (m Model) View() string { func (m Model) View() string {
if m.width == 0 { if m.width == 0 {
@@ -92,7 +92,12 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
switch m.mode { switch m.mode {
case modeDetail: case modeDetail:
actions := styleFooter.Render(" a·ack A·unack c·comment [/]·select d·del s·assign S·stats esc·back") var actions string
if m.activeSection == sectionArchived {
actions = styleFooter.Render(" x·unarchive c·comment [/]·select d·del S·stats esc·back")
} else {
actions = styleFooter.Render(" a·ack A·unack x·archive c·comment [/]·select d·del s·assign S·stats esc·back")
}
if m.statusMsg != "" { if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
} }
@@ -163,6 +168,13 @@ func (m Model) renderFooter() string {
return "\n" + styleFooter.Render(" enter·revoke esc·back") return "\n" + styleFooter.Render(" enter·revoke esc·back")
default: default:
if m.activeSection == sectionArchived {
actions := styleFooter.Render(" x·unarchive enter·detail r·refresh tab·section q·quit")
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
}
return "\n" + actions
}
if m.activeSection == sectionSchedule { if m.activeSection == sectionSchedule {
actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
if m.statusMsg != "" { if m.statusMsg != "" {
@@ -177,6 +189,10 @@ func (m Model) renderFooter() string {
} }
return "\n" + actions return "\n" + actions
} }
var footerLeft string
if m.activeSection == sectionAlerts {
footerLeft = styleFooter.Render(" x·archive")
}
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
if m.statusMsg != "" { if m.statusMsg != "" {
status := styleStatus.Render(m.statusMsg) status := styleStatus.Render(m.statusMsg)
@@ -186,6 +202,13 @@ func (m Model) renderFooter() string {
} }
return "\n" + status + strings.Repeat(" ", gap) + helpView return "\n" + status + strings.Repeat(" ", gap) + helpView
} }
if footerLeft != "" {
gap := m.width - lipgloss.Width(footerLeft) - lipgloss.Width(helpView)
if gap < 0 {
gap = 0
}
return "\n" + footerLeft + strings.Repeat(" ", gap) + helpView
}
return "\n" + helpView return "\n" + helpView
} }
} }
@@ -196,6 +219,8 @@ func (m Model) renderDashboard() string {
switch m.activeSection { switch m.activeSection {
case sectionAlerts: case sectionAlerts:
return m.renderAlerts() return m.renderAlerts()
case sectionArchived:
return m.renderArchived()
case sectionSchedule: case sectionSchedule:
return m.renderSchedule() return m.renderSchedule()
case sectionUsers: case sectionUsers:
@@ -284,6 +309,16 @@ func (m Model) renderAlerts() string {
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content) return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
} }
func (m Model) renderArchived() string {
if m.archivedLoading {
return styleMuted.Render(" Loading archived alerts…")
}
if len(m.archivedAlerts) == 0 {
return styleMuted.Render(" No archived alerts.")
}
return m.archivedTable.View()
}
func (m Model) renderStatsBar() string { func (m Model) renderStatsBar() string {
total, firing, resolved := 0, 0, 0 total, firing, resolved := 0, 0, 0
if m.stats != nil { if m.stats != nil {
@@ -346,7 +381,11 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
if alert.Status == "firing" { if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING") statusStr = styleFiring.Render("● FIRING")
} else { } 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) nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if nameGap < 1 { if nameGap < 1 {
@@ -355,17 +394,19 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n") b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
// Timeline // 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))) 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 { 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 != "" { if alert.GeneratorURL != "" {
url := alert.GeneratorURL url := alert.GeneratorURL
if len(url) > contentW-12 { if len(url) > contentW-12 {
url = url[:contentW-15] + "…" url = url[:contentW-15] + "…"
} }
b.WriteString(fmt.Sprintf(" Source: %s\n", url)) b.WriteString(fmt.Sprintf(" Source: %s\n", url))
} }
b.WriteString("\n") b.WriteString("\n")