diff --git a/internal/api/client.go b/internal/api/client.go index 7f96a73..a0d39c5 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -61,12 +61,16 @@ func (c *Client) do(req *http.Request, out any) error { return nil } -// ListAlerts fetches alerts from the server. status may be "firing", "resolved", or "" for all. -func (c *Client) ListAlerts(status string, limit int) ([]Alert, error) { +// ListAlerts fetches alerts. status may be "firing", "resolved", or "" for all. +// 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{} if status != "" { q.Set("status", status) } + if archived { + q.Set("archived", "true") + } if limit > 0 { 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) } +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. func (c *Client) GetAlertStats() (*AlertStats, error) { req, err := c.newRequest(http.MethodGet, "/api/stats/alerts") diff --git a/internal/api/types.go b/internal/api/types.go index 5e6c8ee..404c0d9 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -16,6 +16,7 @@ type Alert struct { AcknowledgedByID *int64 `json:"acknowledged_by_id"` AcknowledgedBy string `json:"acknowledged_by"` AcknowledgedAt *time.Time `json:"acknowledged_at"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` } type AlertStats struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index 2871d75..cc2c94c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -18,7 +18,8 @@ import ( type section int const ( - sectionAlerts section = iota + sectionAlerts section = iota + sectionArchived sectionSchedule sectionUsers ) @@ -53,6 +54,9 @@ const ( type connectedMsg struct{} type connectErrMsg struct{ err error } 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 fetchDataErrMsg struct{ err error } type tickMsg time.Time @@ -113,6 +117,11 @@ type Model struct { filterStatus string alertTable table.Model + // Archived alerts + archivedAlerts []api.Alert + archivedLoading bool + archivedTable table.Model + // Detail selectedAlert api.Alert comments []api.Comment @@ -168,6 +177,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio alertT := table.New(table.WithFocused(true)) alertT.SetStyles(ts) + archivedT := table.New(table.WithFocused(true)) + archivedT.SetStyles(ts) + schedT := table.New(table.WithFocused(true)) schedT.SetStyles(ts) @@ -215,6 +227,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio filterStatus: "firing", commentCursor: -1, alertTable: alertT, + archivedTable: archivedT, commentInput: ti, scheduleWindow: window, scheduleTable: schedT, @@ -254,6 +267,16 @@ func (m *Model) rebuildTable() { 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() { m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) @@ -466,7 +489,7 @@ func connectCmd(client *api.Client) tea.Cmd { func fetchAlertsCmd(client *api.Client, status string) tea.Cmd { return func() tea.Msg { - alerts, err := client.ListAlerts(status, 500) + alerts, err := client.ListAlerts(status, false, 500) if err != nil { return fetchDataErrMsg{err} } @@ -474,6 +497,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 { return func() tea.Msg { stats, err := client.GetAlertStats() diff --git a/internal/tui/update.go b/internal/tui/update.go index bc2d84b..24ed9dd 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -16,6 +16,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height m.rebuildTable() + m.rebuildArchivedTable() m.rebuildScheduleTable() m.rebuildUserPickerTable() m.rebuildUserManageTable() @@ -49,6 +50,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rebuildTable() 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: m.stats = &msg.stats return m, nil @@ -215,6 +238,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m.alertTable, tableCmd = m.alertTable.Update(msg) m2, ourCmd := m.handleKey(msg) 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: var tableCmd tea.Cmd 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 case "tab": - next := section((int(m.activeSection) + 1) % 3) + next := section((int(m.activeSection) + 1) % 4) 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 { m.scheduleLoading = true from := m.scheduleWindow @@ -284,6 +316,10 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { if !m.connected { return m, connectCmd(m.client) } + if m.activeSection == sectionArchived { + m.archivedLoading = true + return m, fetchArchivedAlertsCmd(m.client) + } if m.activeSection == sectionSchedule { m.scheduleLoading = true from := m.scheduleWindow @@ -319,7 +355,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { case "enter": if m.activeSection == sectionAlerts && len(m.alerts) > 0 { cursor := m.alertTable.Cursor() - if cursor < len(m.alerts) { + if cursor >= 0 && cursor < len(m.alerts) { m.selectedAlert = m.alerts[cursor] m.mode = modeDetail 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) } } + 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 // Schedule-specific keys @@ -447,6 +511,9 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil case "a": + if m.activeSection != sectionAlerts { + return m, nil + } if m.selectedAlert.AcknowledgedByID != nil { m.statusMsg = "already acknowledged" 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) case "A": + if m.activeSection != sectionAlerts { + return m, nil + } if m.selectedAlert.AcknowledgedByID == nil { m.statusMsg = "not acknowledged" return m, clearStatusCmd() } 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": m.mode = modeComment m.commentInput.Reset() diff --git a/internal/tui/view.go b/internal/tui/view.go index 6d0fa13..a0e1668 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -10,7 +10,7 @@ import ( "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 { if m.width == 0 { @@ -92,7 +92,12 @@ func (m Model) renderBody() string { func (m Model) renderFooter() string { switch m.mode { 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 != "" { return styleStatus.Render(" "+m.statusMsg) + "\n" + actions } @@ -163,6 +168,13 @@ func (m Model) renderFooter() string { return "\n" + styleFooter.Render(" enter·revoke esc·back") 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 { actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") if m.statusMsg != "" { @@ -177,6 +189,10 @@ func (m Model) renderFooter() string { } return "\n" + actions } + var footerLeft string + if m.activeSection == sectionAlerts { + footerLeft = styleFooter.Render(" x·archive") + } helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) if m.statusMsg != "" { status := styleStatus.Render(m.statusMsg) @@ -186,6 +202,13 @@ func (m Model) renderFooter() string { } 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 } } @@ -196,6 +219,8 @@ func (m Model) renderDashboard() string { switch m.activeSection { case sectionAlerts: return m.renderAlerts() + case sectionArchived: + return m.renderArchived() case sectionSchedule: return m.renderSchedule() case sectionUsers: @@ -284,6 +309,16 @@ func (m Model) renderAlerts() string { 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 { total, firing, resolved := 0, 0, 0 if m.stats != nil {