diff --git a/CLAUDE.md b/CLAUDE.md index 05bc779..5c154a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,19 @@ # terdut-tui -TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and on-call scheduler. +TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.4.0+**. + +## Domain model + +The server splits alerts from incidents, and this client mirrors it: + +- **Alert** — Alertmanager's record. Firing or resolved, read-only, no workflow state. +- **Incident** — the work item: triggered → acknowledged → resolved, with an + assignee, snooze, notes and an append-only timeline. Many alerts to one incident, + correlated by Alertmanager's `groupKey`. + +All user actions target incidents. Two server behaviours the UI has to respect: +manual resolve is **terminal** (hence the confirmation prompt), and snooze is the +non-destructive "not now" alternative. ## Tech stack @@ -57,6 +70,11 @@ go run . --self-update go build -ldflags="-X main.version=v0.1.0" -o terdut-tui . ``` +## Sections + +`Incidents` (the queue, and the default) · `Alerts` (raw read-only feed) · +`Archived` (archived incidents) · `Schedule` · `Users` + ## Development stages | Stage | Feature | @@ -66,6 +84,7 @@ 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 | +| 6 | Incidents: queue, timeline, ack/assign/snooze/resolve, MTTA/MTTR | ## Memory (GrayMatter) diff --git a/README.md b/README.md index 4d8d65a..48557b9 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,37 @@ Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). ## Features -- **Alert dashboard** — live view of firing and resolved alerts with auto-refresh -- **Alert actions** — acknowledge, comment, and view per-alert statistics +- **Incident queue** — open incidents with severity, status, assignee and age, auto-refreshing +- **Incident actions** — acknowledge, assign, snooze, note, resolve and archive +- **Timeline** — the full history of an incident, system events and notes together +- **Alert feed** — the raw read-only alerts underneath, each linked to its incident - **On-call schedule** — visual calendar of who is on duty, assign and remove entries +- **Statistics** — MTTA and MTTR, plus alert frequency by name, hour and day - **User management** — add and remove users, manage API keys +> Requires terdut-server **v0.4.0 or later**. Earlier servers have no incidents API; +> use terdut-tui v0.3.x with those. + +## Alerts and incidents + +The server keeps two objects and this client follows that split: + +- An **alert** is Alertmanager's record — firing or resolved, and read-only here. +- An **incident** is the work item. It is what you acknowledge, assign, snooze, + discuss and resolve, and it is where all the actions live. + +Incidents are correlated by the `groupKey` Alertmanager already computed from your +`group_by` configuration, so several alerts commonly share one incident. + +Two behaviours worth knowing before you press a key: + +- **Resolving is final.** The server treats a manual resolve as terminal: a later + occurrence opens a *new* incident rather than reopening this one, and if the alert + underneath never stops firing the incident stays closed. The TUI asks for + confirmation before doing it. +- **Snooze is the "not now" button.** It hides an incident from the default queue + without closing it, and expires on its own. + ## Installation Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: @@ -41,13 +67,59 @@ terdut-tui --self-update update to the latest release ### Keybindings +Global: + | Key | Action | |-----|--------| | `j` / `↓` | Move down | | `k` / `↑` | Move up | -| `tab` | Switch section (Alerts / Schedule / Users) | -| `enter` | Select / open detail | +| `tab` / `shift+tab` | Next / previous section | +| `enter` | Open detail | | `esc` | Go back | | `r` | Refresh | -| `f` | Filter / cycle filter | +| `f` | Cycle filter | +| `S` | Statistics | | `q` | Quit | + +Incidents section: + +| Key | Action | +|-----|--------| +| `f` | Cycle: open → triggered → acknowledged → resolved → snoozed | +| `x` | Archive (resolved incidents only) | + +Incident detail: + +| Key | Action | +|-----|--------| +| `a` / `A` | Acknowledge / clear acknowledgement | +| `R` | Resolve — asks to confirm, and is final | +| `s` | Assign to a user | +| `z` / `Z` | Snooze for a duration / un-snooze | +| `c` | Add a note | +| `[` / `]` | Select a note | +| `d` | Delete the selected note (your own only) | +| `x` | Archive / un-archive | + +Alerts section (read-only): + +| Key | Action | +|-----|--------| +| `f` | Cycle: firing → resolved → all → archived | +| `i` | In detail: jump to the alert's incident | + +Schedule section: + +| Key | Action | +|-----|--------| +| `+` / `W` | Assign a day / a whole week | +| `d` | Remove the assignment | +| `←` / `→` | Shift the week window | + +Users section: + +| Key | Action | +|-----|--------| +| `n` | Create a user | +| `d` | Delete a user | +| `k` | API keys for the selected user | diff --git a/internal/api/client.go b/internal/api/client.go index a0d39c5..8322072 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -63,6 +63,10 @@ func (c *Client) do(req *http.Request, out any) 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. +// +// Alerts are read-only on the server — there is nothing to acknowledge or +// archive here. This is the raw feed, useful for checking what Alertmanager is +// actually sending; the work queue is ListIncidents. func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, error) { q := url.Values{} if status != "" { @@ -87,23 +91,6 @@ func (c *Client) ListAlerts(status string, archived bool, limit int) ([]Alert, e 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") @@ -138,49 +125,172 @@ func (c *Client) GetAlert(id int64) (*Alert, error) { return &alert, c.do(req, &alert) } -func (c *Client) AcknowledgeAlert(id int64) (*Alert, error) { - req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/alerts/%d/acknowledge", id)) +// ── Incidents ────────────────────────────────────────────────────────────── + +// ListIncidents fetches the work queue. status may be "triggered", +// "acknowledged", "resolved", or "" for the server default of open incidents +// only. archived and snoozed each switch the list to that set rather than +// adding to it, matching the server's filters. +func (c *Client) ListIncidents(status string, archived, snoozed bool, limit int) ([]Incident, error) { + q := url.Values{} + if status != "" { + q.Set("status", status) + } + if archived { + q.Set("archived", "true") + } + if snoozed { + q.Set("snoozed", "true") + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + path := "/api/incidents" + if len(q) > 0 { + path += "?" + q.Encode() + } + + req, err := c.newRequest(http.MethodGet, path) if err != nil { return nil, err } - var alert Alert - return &alert, c.do(req, &alert) + var incidents []Incident + return incidents, c.do(req, &incidents) } -func (c *Client) UnacknowledgeAlert(id int64) error { - req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/acknowledge", id)) +// GetIncident returns one incident with its member alerts inline. +func (c *Client) GetIncident(id int64) (*Incident, error) { + req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d", id)) + if err != nil { + return nil, err + } + var incident Incident + return &incident, c.do(req, &incident) +} + +func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) { + req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id)) + if err != nil { + return nil, err + } + var events []IncidentEvent + return events, c.do(req, &events) +} + +func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) { + req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id)) + if err != nil { + return nil, err + } + var incident Incident + return &incident, c.do(req, &incident) +} + +func (c *Client) UnacknowledgeIncident(id int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/acknowledge", id)) if err != nil { return err } return c.do(req, nil) } -func (c *Client) GetComments(alertID int64) ([]Comment, error) { - req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d/comments", alertID)) +// ResolveIncident closes an incident by hand. This is terminal on the server: a +// later occurrence in the same group opens a new incident rather than reopening +// this one, and if the alert underneath never stops firing the incident stays +// closed. Use SnoozeIncident for "not now". +func (c *Client) ResolveIncident(id int64) (*Incident, error) { + req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/resolve", id)) if err != nil { return nil, err } - var comments []Comment - return comments, c.do(req, &comments) + var incident Incident + return &incident, c.do(req, &incident) } -func (c *Client) AddComment(alertID int64, content string) (*Comment, error) { - req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/alerts/%d/comments", alertID), map[string]string{"content": content}) +func (c *Client) AssignIncident(id, userID int64) (*Incident, error) { + body := struct { + UserID int64 `json:"user_id"` + }{UserID: userID} + req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/assign", id), body) if err != nil { return nil, err } - var comment Comment - return &comment, c.do(req, &comment) + var incident Incident + return &incident, c.do(req, &incident) } -func (c *Client) DeleteComment(alertID, commentID int64) error { - req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/alerts/%d/comments/%d", alertID, commentID)) +// SnoozeIncident hides an incident from the default queue for a duration, +// without closing it. +func (c *Client) SnoozeIncident(id int64, duration string) (*Incident, error) { + body := struct { + Duration string `json:"duration"` + }{Duration: duration} + req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/snooze", id), body) + if err != nil { + return nil, err + } + var incident Incident + return &incident, c.do(req, &incident) +} + +func (c *Client) UnsnoozeIncident(id int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/snooze", id)) if err != nil { return err } return c.do(req, nil) } +func (c *Client) ArchiveIncident(id int64) (*Incident, error) { + req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/archive", id)) + if err != nil { + return nil, err + } + var incident Incident + return &incident, c.do(req, &incident) +} + +func (c *Client) UnarchiveIncident(id int64) error { + req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/archive", id)) + if err != nil { + return err + } + return c.do(req, nil) +} + +// AddNote appends a note to the incident's timeline. +func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) { + req, err := c.newRequestWithBody(http.MethodPost, + fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content}) + if err != nil { + return nil, err + } + var event IncidentEvent + return &event, c.do(req, &event) +} + +// DeleteNote removes one of your own notes. Only notes are deletable — the rest +// of the timeline is a record of what happened. +func (c *Client) DeleteNote(incidentID, eventID int64) error { + req, err := c.newRequest(http.MethodDelete, + fmt.Sprintf("/api/incidents/%d/notes/%d", incidentID, eventID)) + if err != nil { + return err + } + return c.do(req, nil) +} + +func (c *Client) GetIncidentStats() (*IncidentStats, error) { + req, err := c.newRequest(http.MethodGet, "/api/stats/incidents") + if err != nil { + return nil, err + } + var stats IncidentStats + return &stats, c.do(req, &stats) +} + +// ── Statistics ───────────────────────────────────────────────────────────── + func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) { req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit)) if err != nil { @@ -232,7 +342,9 @@ func (c *Client) GetCurrentOnCall() (*ScheduleEntry, error) { return nil, nil } if resp.StatusCode >= 400 { - var e struct{ Error string `json:"error"` } + var e struct { + Error string `json:"error"` + } _ = json.NewDecoder(resp.Body).Decode(&e) if e.Error != "" { return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, e.Error) diff --git a/internal/api/types.go b/internal/api/types.go index 7b4cb6b..afa3a89 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -2,21 +2,26 @@ package api import "time" +// Alert is the server's record of what Alertmanager said. It is read-only: +// acknowledging, assigning, noting and resolving all happen on the Incident an +// alert belongs to. type Alert struct { - ID int64 `json:"id"` - Fingerprint string `json:"fingerprint"` - Name string `json:"name"` - Status string `json:"status"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` - StartsAt time.Time `json:"starts_at"` - EndsAt *time.Time `json:"ends_at"` - GeneratorURL string `json:"generator_url"` - ReceivedAt time.Time `json:"received_at"` - AcknowledgedByID *int64 `json:"acknowledged_by_id"` - AcknowledgedBy string `json:"acknowledged_by"` - AcknowledgedAt *time.Time `json:"acknowledged_at"` - ArchivedAt *time.Time `json:"archived_at,omitempty"` + ID int64 `json:"id"` + Fingerprint string `json:"fingerprint"` + Name string `json:"name"` + Status string `json:"status"` + Labels map[string]string `json:"labels"` + Annotations map[string]string `json:"annotations"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + GeneratorURL string `json:"generator_url"` + ReceivedAt time.Time `json:"received_at"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + + // IncidentID is the most recent incident this alert belongs to. An alert row + // is reused across occurrences of the same fingerprint, so it belongs to a + // series of incidents over its life and this is only the newest. + IncidentID *int64 `json:"incident_id,omitempty"` // ResolutionSource records why a resolved alert left the firing state: // "alertmanager" for a real resolved webhook, "expiry" when the server @@ -24,19 +29,104 @@ type Alert struct { ResolutionSource *string `json:"resolution_source,omitempty"` } +// Incident statuses. +const ( + StatusTriggered = "triggered" + StatusAcknowledged = "acknowledged" + StatusResolved = "resolved" +) + +// Incident is the work item: what a person acknowledges, assigns, snoozes, +// discusses and resolves. Many alerts map to one incident, correlated by the +// groupKey Alertmanager computed from the operator's group_by configuration. +type Incident struct { + ID int64 `json:"id"` + GroupKey string `json:"group_key"` + Title string `json:"title"` + GroupLabels map[string]string `json:"group_labels"` + Status string `json:"status"` + + // Severity is a high-water mark across the incident's alerts, never lowered, + // so a resolved incident still says how bad it got. + Severity string `json:"severity,omitempty"` + + TriggeredAt time.Time `json:"triggered_at"` + + AcknowledgedByID *int64 `json:"acknowledged_by_id,omitempty"` + AcknowledgedBy string `json:"acknowledged_by,omitempty"` + AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` + + AssignedToID *int64 `json:"assigned_to_id,omitempty"` + AssignedTo string `json:"assigned_to,omitempty"` + + SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` + + ResolvedAt *time.Time `json:"resolved_at,omitempty"` + + // ResolutionSource is "alerts" when every alert stopped firing, or "manual" + // when a person closed it. Treat the value set as open. + ResolutionSource *string `json:"resolution_source,omitempty"` + + ArchivedAt *time.Time `json:"archived_at,omitempty"` + + // Alerts is populated by GET /api/incidents/{id} only. + Alerts []Alert `json:"alerts,omitempty"` +} + +// IsSnoozed reports whether the incident is currently quietened. A snooze +// expires by falling into the past; nothing on the server sweeps it. +func (i Incident) IsSnoozed() bool { + return i.SnoozedUntil != nil && i.SnoozedUntil.After(time.Now()) +} + +// IsOpen reports whether the incident is still work in progress. +func (i Incident) IsOpen() bool { return i.ResolvedAt == nil } + +// Incident timeline event types written by the server. New ones may be added, +// so render unrecognised types generically rather than dropping them. +const ( + EventTriggered = "triggered" + EventAlertAdded = "alert_added" + EventAlertResolved = "alert_resolved" + EventAcknowledged = "acknowledged" + EventUnacknowledged = "unacknowledged" + EventAssigned = "assigned" + EventSnoozed = "snoozed" + EventUnsnoozed = "unsnoozed" + EventResolved = "resolved" + EventNote = "note" +) + +// IncidentEvent is one entry in an incident's timeline. An empty Username means +// the server acted rather than a person. On an "assigned" event the user is the +// assignee, not the actor. +type IncidentEvent struct { + ID int64 `json:"id"` + IncidentID int64 `json:"incident_id"` + Type string `json:"type"` + UserID *int64 `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + AlertID *int64 `json:"alert_id,omitempty"` + Detail string `json:"detail,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + type AlertStats struct { Total int `json:"total"` Firing int `json:"firing"` Resolved int `json:"resolved"` } -type Comment struct { - ID int64 `json:"id"` - AlertID int64 `json:"alert_id"` - UserID int64 `json:"user_id"` - Username string `json:"username"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` +// IncidentStats carries the queue counts plus mean time to acknowledge and to +// resolve. Both averages are nil until something has actually been acknowledged +// or resolved — that is "no data", not zero. +type IncidentStats struct { + Total int `json:"total"` + Triggered int `json:"triggered"` + Acknowledged int `json:"acknowledged"` + Resolved int `json:"resolved"` + MTTASeconds *float64 `json:"mtta_seconds"` + MTTRSeconds *float64 `json:"mttr_seconds"` } type TopAlert struct { diff --git a/internal/tui/model.go b/internal/tui/model.go index f0f8148..1d35cc1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -18,21 +18,27 @@ import ( type section int const ( - sectionAlerts section = iota + // Incidents lead: they are the work. Alerts is the raw feed underneath. + sectionIncidents section = iota + sectionAlerts sectionArchived sectionSchedule sectionUsers + + sectionCount = 5 ) type mode int const ( modeDashboard mode = iota - modeDetail - modeComment - modeConfirmDelete + modeIncidentDetail + modeAlertDetail + modeNote + modeSnooze + modeConfirm modeStats - modeScheduleUserPicker + modeUserPicker modeUserCreate modeAPIKeyMenu modeAPIKeyCreate @@ -43,31 +49,70 @@ const ( type confirmTarget int const ( - confirmDeleteComment confirmTarget = iota + confirmDeleteNote confirmTarget = iota + confirmResolveIncident confirmDeleteScheduleEntry confirmDeleteUser ) +// pickerTarget says what the user picker is choosing a person for. +type pickerTarget int + +const ( + pickerSchedule pickerTarget = iota + pickerIncidentAssignee +) + +// incidentFilters is the cycle the f key walks in the Incidents section. The +// empty string is the server default: open, unsnoozed incidents — the queue. +var incidentFilters = []string{"", api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved, "snoozed"} + +// alertFilters is the equivalent cycle for the raw alert feed. +var alertFilters = []string{"firing", "resolved", "", "archived"} + +// incidentQuery translates a filter from the cycle into server query terms. +func incidentQuery(filter string) (status string, snoozed bool) { + if filter == "snoozed" { + return "", true + } + return filter, false +} + +// filterLabel renders a filter for the status bar. +func filterLabel(filter string) string { + if filter == "" { + return "open" + } + return filter +} + // ── Messages ─────────────────────────────────────────────────────────────── // dashboard type connectedMsg struct{} type connectErrMsg struct{ err error } +type incidentsFetchedMsg struct{ incidents []api.Incident } +type archivedIncidentsFetchedMsg struct{ incidents []api.Incident } +type incidentActionDoneMsg struct { + incidents []api.Incident + status string +} 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 { + incidents api.IncidentStats + alerts api.AlertStats +} type fetchDataErrMsg struct{ err error } type tickMsg time.Time type clearStatusMsg struct{} // detail -type alertDetailFetchedMsg struct { - alert api.Alert - comments []api.Comment +type incidentDetailFetchedMsg struct { + incident api.Incident + timeline []api.IncidentEvent } -type alertDetailErrMsg struct{ err error } +type alertDetailFetchedMsg struct{ alert api.Alert } +type detailErrMsg struct{ err error } type actionErrMsg struct{ err error } type detailStatsFetchedMsg struct { top []api.TopAlert @@ -108,34 +153,46 @@ type Model struct { height int // Connection & dashboard - connected bool - loading bool - err error - statusMsg string - alerts []api.Alert - stats *api.AlertStats - filterStatus string - alertTable table.Model + connected bool + loading bool + err error + statusMsg string + incidentStats *api.IncidentStats + alertStats *api.AlertStats - // Archived alerts - archivedAlerts []api.Alert - archivedLoading bool - archivedTable table.Model + // Incidents + incidents []api.Incident + incidentFilter string + incidentTable table.Model - // Detail - selectedAlert api.Alert - comments []api.Comment - commentCursor int - detailLoading bool - detailViewport viewport.Model + // Alerts (read-only feed) + alerts []api.Alert + alertFilter string + alertTable table.Model - // Comment compose - commentInput textinput.Model + // Archived incidents + archivedIncidents []api.Incident + archivedLoading bool + archivedTable table.Model - // Confirm delete - confirmTarget confirmTarget - pendingDeleteID int64 // comment ID - pendingDeleteEntry *api.ScheduleEntry + // Incident detail + selectedIncident api.Incident + timeline []api.IncidentEvent + noteCursor int + detailLoading bool + detailViewport viewport.Model + + // Alert detail + selectedAlert api.Alert + + // Note compose & snooze + noteInput textinput.Model + snoozeInput textinput.Model + + // Confirm + confirmTarget confirmTarget + pendingDeleteID int64 // note event ID + pendingDeleteEntry *api.ScheduleEntry // Stats topAlerts []api.TopAlert @@ -143,6 +200,9 @@ type Model struct { dayStats []api.DayStat statsLoading bool statsViewport viewport.Model + // statsReturnMode is where esc goes back to, since stats opens from both + // the dashboard and an incident. + statsReturnMode mode // Schedule scheduleWindow time.Time @@ -152,10 +212,11 @@ type Model struct { scheduleLoading bool scheduleTable table.Model - // User picker (schedule assignment) + // User picker (schedule assignment and incident assignee) users []api.User usersLoading bool userPickerTable table.Model + pickerTarget pickerTarget pickerAssignWeek bool // User management section @@ -174,6 +235,9 @@ type Model struct { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { ts := defaultTableStyles() + incidentT := table.New(table.WithFocused(true)) + incidentT.SetStyles(ts) + alertT := table.New(table.WithFocused(true)) alertT.SetStyles(ts) @@ -189,9 +253,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio manageT := table.New(table.WithFocused(true)) manageT.SetStyles(ts) - ti := textinput.New() - ti.Placeholder = "type your comment…" - ti.CharLimit = 1000 + noteIn := textinput.New() + noteIn.Placeholder = "type your note…" + noteIn.CharLimit = 1000 + + snoozeIn := textinput.New() + snoozeIn.Placeholder = "duration, e.g. 2h or 30m" + snoozeIn.CharLimit = 16 usernameIn := textinput.New() usernameIn.Placeholder = "username" @@ -221,14 +289,17 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio client: client, serverURL: serverURL, refreshInterval: refreshInterval, - activeSection: sectionAlerts, + activeSection: sectionIncidents, mode: modeDashboard, loading: true, - filterStatus: "firing", - commentCursor: -1, + incidentFilter: "", + alertFilter: "firing", + noteCursor: -1, + incidentTable: incidentT, alertTable: alertT, archivedTable: archivedT, - commentInput: ti, + noteInput: noteIn, + snoozeInput: snoozeIn, scheduleWindow: window, scheduleTable: schedT, userPickerTable: pickerT, @@ -257,34 +328,28 @@ func defaultTableStyles() table.Styles { return s } +func (m *Model) rebuildIncidentTable() { + m.incidentTable.SetColumns(incidentColumns(m.width)) + m.incidentTable.SetRows(incidentRows(m.incidents)) + m.incidentTable.SetHeight(tableHeight(m.height, 8)) +} + func (m *Model) rebuildTable() { m.alertTable.SetColumns(alertColumns(m.width)) m.alertTable.SetRows(alertRows(m.alerts)) - h := m.height - 8 - if h < 1 { - h = 1 - } - m.alertTable.SetHeight(h) + m.alertTable.SetHeight(tableHeight(m.height, 8)) } 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) + m.archivedTable.SetColumns(incidentColumns(m.width)) + m.archivedTable.SetRows(incidentRows(m.archivedIncidents)) + m.archivedTable.SetHeight(tableHeight(m.height, 8)) } func (m *Model) rebuildScheduleTable() { m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) - h := m.height - 10 - if h < 1 { - h = 1 - } - m.scheduleTable.SetHeight(h) + m.scheduleTable.SetHeight(tableHeight(m.height, 10)) } func (m *Model) rebuildUserPickerTable() { @@ -294,11 +359,7 @@ func (m *Model) rebuildUserPickerTable() { rows[i] = table.Row{u.Username, u.Email} } m.userPickerTable.SetRows(rows) - h := m.height - 10 - if h < 1 { - h = 1 - } - m.userPickerTable.SetHeight(h) + m.userPickerTable.SetHeight(tableHeight(m.height, 10)) } func (m *Model) rebuildUserManageTable() { @@ -308,27 +369,37 @@ func (m *Model) rebuildUserManageTable() { rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")} } m.userManageTable.SetRows(rows) - h := m.height - 10 + m.userManageTable.SetHeight(tableHeight(m.height, 10)) +} + +func tableHeight(windowHeight, chrome int) int { + h := windowHeight - chrome if h < 1 { h = 1 } - m.userManageTable.SetHeight(h) + return h } func (m *Model) refreshDetailContent() { if m.width == 0 { return } - m.detailViewport.SetContent(buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)) + if m.mode == modeAlertDetail { + m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width)) + return + } + m.detailViewport.SetContent( + buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width)) } func (m *Model) refreshStatsContent() { - m.statsViewport.SetContent(buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)) + m.statsViewport.SetContent( + buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) } func (m Model) detailViewportHeight() int { h := m.height - 5 - if m.mode == modeComment { + if m.mode == modeNote || m.mode == modeSnooze { h -= 2 } if h < 1 { @@ -337,8 +408,40 @@ func (m Model) detailViewportHeight() int { return h } +// noteEvents filters a timeline down to the deletable entries, which is what +// the [ and ] cursor walks. +func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent { + notes := make([]api.IncidentEvent, 0, len(timeline)) + for _, e := range timeline { + if e.Type == api.EventNote { + notes = append(notes, e) + } + } + return notes +} + // ── Column definitions ───────────────────────────────────────────────────── +func incidentColumns(width int) []table.Column { + const sevW, statusW, timeW = 9, 15, 12 + titleW := width/2 - 10 + if titleW < 20 { + titleW = 20 + } + // 10 = bubbles' Padding(0, 1) on each of the five cells. + assigneeW := width - sevW - titleW - statusW - timeW - 10 + if assigneeW < 8 { + assigneeW = 8 + } + return []table.Column{ + {Title: "Sev", Width: sevW}, + {Title: "Incident", Width: titleW}, + {Title: "Status", Width: statusW}, + {Title: "Assignee", Width: assigneeW}, + {Title: "Triggered", Width: timeW}, + } +} + func alertColumns(width int) []table.Column { const statusW, timeW = 10, 12 nameW := width/2 - 14 @@ -346,16 +449,16 @@ func alertColumns(width int) []table.Column { nameW = 20 } // 10 = bubbles' Padding(0, 1) on each of the five cells. - ackW := width - nameW - statusW - 2*timeW - 10 - if ackW < 8 { - ackW = 8 + incW := width - nameW - statusW - 2*timeW - 10 + if incW < 8 { + incW = 8 } return []table.Column{ {Title: "Name", Width: nameW}, {Title: "Status", Width: statusW}, {Title: "Started", Width: timeW}, {Title: "Last Seen", Width: timeW}, - {Title: "Ack By", Width: ackW}, + {Title: "Incident", Width: incW}, } } @@ -399,11 +502,38 @@ func userManageColumns(width int) []table.Column { // ── Row builders ─────────────────────────────────────────────────────────── +func incidentRows(incidents []api.Incident) []table.Row { + now := time.Now() + rows := make([]table.Row, len(incidents)) + for i, inc := range incidents { + severity := inc.Severity + if severity == "" { + severity = "—" + } + // bubbles' table renders plain strings, so a snoozed incident is marked + // in the status cell rather than styled. + status := inc.Status + if inc.IsSnoozed() { + status += " (zzz)" + } + assignee := inc.AssignedTo + if assignee == "" { + assignee = "—" + } + rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)} + } + return rows +} + 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), humanAgo(now, a.ReceivedAt), a.AcknowledgedBy} + incident := "—" + if a.IncidentID != nil { + incident = fmt.Sprintf("#%d", *a.IncidentID) + } + rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident} } return rows } @@ -457,28 +587,49 @@ func humanAgo(now, t time.Time) string { if d < 0 { d = 0 } + return humanDuration(d) + " ago" +} + +// humanUntil renders a future deadline, used for snooze expiry. +func humanUntil(now, t time.Time) string { + d := t.Sub(now) + if d <= 0 { + return "expired" + } + return "in " + humanDuration(d) +} + +func humanDuration(d time.Duration) string { switch { case d < time.Minute: - return "just now" + return "moments" case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) + return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: h := int(d.Hours()) m := int(d.Minutes()) % 60 if m == 0 { - return fmt.Sprintf("%dh ago", h) + return fmt.Sprintf("%dh", h) } - return fmt.Sprintf("%dh %dm ago", h, m) + return fmt.Sprintf("%dh %dm", h, m) default: days := int(d.Hours()) / 24 h := int(d.Hours()) % 24 if h == 0 { - return fmt.Sprintf("%dd ago", days) + return fmt.Sprintf("%dd", days) } - return fmt.Sprintf("%dd %dh ago", days, h) + return fmt.Sprintf("%dd %dh", days, h) } } +// humanSeconds renders an MTTA/MTTR average. +func humanSeconds(secs *float64) string { + if secs == nil { + return "—" + } + return humanDuration(time.Duration(*secs) * time.Second) +} + // ── Commands ─────────────────────────────────────────────────────────────── func connectCmd(client *api.Client) tea.Cmd { @@ -490,9 +641,36 @@ func connectCmd(client *api.Client) tea.Cmd { } } -func fetchAlertsCmd(client *api.Client, status string) tea.Cmd { +func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd { return func() tea.Msg { - alerts, err := client.ListAlerts(status, false, 500) + status, snoozed := incidentQuery(filter) + incidents, err := client.ListIncidents(status, false, snoozed, 500) + if err != nil { + return fetchDataErrMsg{err} + } + return incidentsFetchedMsg{incidents} + } +} + +func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd { + return func() tea.Msg { + // Archived incidents are all resolved, so the status filter has to be + // widened past the server's open-only default or nothing comes back. + incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) + if err != nil { + return fetchDataErrMsg{err} + } + return archivedIncidentsFetchedMsg{incidents} + } +} + +func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd { + return func() tea.Msg { + status, archived := filter, false + if filter == "archived" { + status, archived = "", true + } + alerts, err := client.ListAlerts(status, archived, 500) if err != nil { return fetchDataErrMsg{err} } @@ -500,49 +678,122 @@ 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() + incidents, err := client.GetIncidentStats() if err != nil { return fetchDataErrMsg{err} } - return statsFetchedMsg{*stats} + alerts, err := client.GetAlertStats() + if err != nil { + return fetchDataErrMsg{err} + } + return statsFetchedMsg{incidents: *incidents, alerts: *alerts} + } +} + +// incidentDetail reloads an incident and its timeline. Every detail-mode action +// funnels through it so the view always reflects what the server just did. +func incidentDetail(client *api.Client, id int64) tea.Msg { + incident, err := client.GetIncident(id) + if err != nil { + return detailErrMsg{err} + } + timeline, err := client.GetIncidentTimeline(id) + if err != nil { + return detailErrMsg{err} + } + return incidentDetailFetchedMsg{incident: *incident, timeline: timeline} +} + +func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd { + return func() tea.Msg { return incidentDetail(client, id) } +} + +// incidentActionCmd performs an action then reloads the detail view, reporting +// the server's error rather than a stale success. +func incidentActionCmd(client *api.Client, id int64, action func() error) tea.Cmd { + return func() tea.Msg { + if err := action(); err != nil { + return actionErrMsg{err} + } + return incidentDetail(client, id) + } +} + +func acknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { + _, err := client.AcknowledgeIncident(id) + return err + }) +} + +func unacknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { return client.UnacknowledgeIncident(id) }) +} + +func resolveIncidentCmd(client *api.Client, id int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { + _, err := client.ResolveIncident(id) + return err + }) +} + +func assignIncidentCmd(client *api.Client, id, userID int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { + _, err := client.AssignIncident(id, userID) + return err + }) +} + +func snoozeIncidentCmd(client *api.Client, id int64, duration string) tea.Cmd { + return incidentActionCmd(client, id, func() error { + _, err := client.SnoozeIncident(id, duration) + return err + }) +} + +func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) }) +} + +func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd { + return incidentActionCmd(client, id, func() error { + _, err := client.AddNote(id, content) + return err + }) +} + +func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd { + return incidentActionCmd(client, id, func() error { return client.DeleteNote(id, eventID) }) +} + +// archiveIncidentCmd archives from the list view, so it reloads the list rather +// than a detail pane. +func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd { + return func() tea.Msg { + if _, err := client.ArchiveIncident(id); err != nil { + return actionErrMsg{err} + } + status, snoozed := incidentQuery(filter) + incidents, err := client.ListIncidents(status, false, snoozed, 500) + if err != nil { + return actionErrMsg{err} + } + return incidentActionDoneMsg{incidents: incidents, status: "Incident archived"} + } +} + +func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd { + return func() tea.Msg { + if err := client.UnarchiveIncident(id); err != nil { + return actionErrMsg{err} + } + incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500) + if err != nil { + return actionErrMsg{err} + } + return archivedIncidentsFetchedMsg{incidents} } } @@ -550,78 +801,9 @@ func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd { return func() tea.Msg { alert, err := client.GetAlert(alertID) if err != nil { - return alertDetailErrMsg{err} + return detailErrMsg{err} } - comments, err := client.GetComments(alertID) - if err != nil { - return alertDetailErrMsg{err} - } - return alertDetailFetchedMsg{alert: *alert, comments: comments} - } -} - -func acknowledgeCmd(client *api.Client, alertID int64) tea.Cmd { - return func() tea.Msg { - alert, err := client.AcknowledgeAlert(alertID) - if err != nil { - return actionErrMsg{err} - } - comments, err := client.GetComments(alertID) - if err != nil { - return actionErrMsg{err} - } - return alertDetailFetchedMsg{alert: *alert, comments: comments} - } -} - -func unacknowledgeCmd(client *api.Client, alertID int64) tea.Cmd { - return func() tea.Msg { - if err := client.UnacknowledgeAlert(alertID); err != nil { - return actionErrMsg{err} - } - alert, err := client.GetAlert(alertID) - if err != nil { - return actionErrMsg{err} - } - comments, err := client.GetComments(alertID) - if err != nil { - return actionErrMsg{err} - } - return alertDetailFetchedMsg{alert: *alert, comments: comments} - } -} - -func addCommentCmd(client *api.Client, alertID int64, content string) tea.Cmd { - return func() tea.Msg { - if _, err := client.AddComment(alertID, content); err != nil { - return actionErrMsg{err} - } - alert, err := client.GetAlert(alertID) - if err != nil { - return actionErrMsg{err} - } - comments, err := client.GetComments(alertID) - if err != nil { - return actionErrMsg{err} - } - return alertDetailFetchedMsg{alert: *alert, comments: comments} - } -} - -func deleteCommentCmd(client *api.Client, alertID, commentID int64) tea.Cmd { - return func() tea.Msg { - if err := client.DeleteComment(alertID, commentID); err != nil { - return actionErrMsg{err} - } - alert, err := client.GetAlert(alertID) - if err != nil { - return actionErrMsg{err} - } - comments, err := client.GetComments(alertID) - if err != nil { - return actionErrMsg{err} - } - return alertDetailFetchedMsg{alert: *alert, comments: comments} + return alertDetailFetchedMsg{alert: *alert} } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 3309613..ada31ea 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -1,6 +1,11 @@ package tui -import "github.com/charmbracelet/lipgloss" +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/yeniklas/terdut-tui/internal/api" +) var ( colorPrimary = lipgloss.Color("69") // blue @@ -9,6 +14,11 @@ var ( colorResolved = lipgloss.Color("70") // green colorAccent = lipgloss.Color("214") // orange + colorSevCritical = lipgloss.Color("196") // red + colorSevError = lipgloss.Color("202") // dark orange + colorSevWarning = lipgloss.Color("214") // orange + colorSevInfo = lipgloss.Color("39") // cyan + styleHeader = lipgloss.NewStyle(). Bold(true). Foreground(colorPrimary). @@ -42,4 +52,48 @@ var ( styleBold = lipgloss.NewStyle().Bold(true) styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) styleAccent = lipgloss.NewStyle().Foreground(colorAccent) + + // Incident status. Triggered is unclaimed work and reads as loudly as a + // firing alert; acknowledged means somebody has it. + styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) + styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true) + + // Severity, over the conventional Alertmanager label values. + styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true) + styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true) + styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning) + styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo) ) + +// severityStyle picks the style for a severity label, falling back to muted for +// values this client does not recognise rather than dropping them. +func severityStyle(severity string) lipgloss.Style { + switch strings.ToLower(severity) { + case "critical": + return styleSevCritical + case "error": + return styleSevError + case "warning": + return styleSevWarning + case "info": + return styleSevInfo + default: + return styleMuted + } +} + +// incidentStatusStyle picks the style for an incident status, falling back to +// muted for statuses added after this client was built. +func incidentStatusStyle(status string) lipgloss.Style { + switch status { + case api.StatusTriggered: + return styleTriggered + case api.StatusAcknowledged: + return styleAcknowledged + case api.StatusResolved: + return styleResolved + default: + return styleMuted + } +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 24ed9dd..e316ccf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -15,6 +15,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.rebuildIncidentTable() m.rebuildTable() m.rebuildArchivedTable() m.rebuildScheduleTable() @@ -35,7 +36,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = nil return m, tea.Batch( tickCmd(m.refreshInterval), - fetchAlertsCmd(m.client, m.filterStatus), + fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client), ) @@ -44,62 +45,66 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err return m, nil + case incidentsFetchedMsg: + m.incidents = msg.incidents + m.loading = false + m.rebuildIncidentTable() + return m, nil + + case archivedIncidentsFetchedMsg: + m.archivedIncidents = msg.incidents + m.archivedLoading = false + m.rebuildArchivedTable() + m.mode = modeDashboard + return m, nil + + case incidentActionDoneMsg: + m.incidents = msg.incidents + m.loading = false + m.rebuildIncidentTable() + m.mode = modeDashboard + m.statusMsg = msg.status + return m, clearStatusCmd() + case alertsFetchedMsg: m.alerts = msg.alerts m.loading = false 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 + m.incidentStats = &msg.incidents + m.alertStats = &msg.alerts return m, nil case fetchDataErrMsg: + m.loading = false + m.archivedLoading = false m.statusMsg = "refresh error: " + msg.err.Error() return m, clearStatusCmd() case tickMsg: - return m, tea.Batch( - tickCmd(m.refreshInterval), - fetchAlertsCmd(m.client, m.filterStatus), - fetchStatsCmd(m.client), - ) + return m, tea.Batch(tickCmd(m.refreshInterval), m.refreshActiveSection()) // ── Detail messages ─────────────────────────────────────────────────── - case alertDetailFetchedMsg: - m.selectedAlert = msg.alert - m.comments = msg.comments + case incidentDetailFetchedMsg: + m.selectedIncident = msg.incident + m.timeline = msg.timeline m.detailLoading = false - if m.commentCursor >= len(m.comments) { - m.commentCursor = -1 + if m.noteCursor >= len(noteEvents(m.timeline)) { + m.noteCursor = -1 } m.refreshDetailContent() return m, nil - case alertDetailErrMsg: + case alertDetailFetchedMsg: + m.selectedAlert = msg.alert + m.detailLoading = false + m.refreshDetailContent() + return m, nil + + case detailErrMsg: m.detailLoading = false m.statusMsg = "error: " + msg.err.Error() return m, clearStatusCmd() @@ -119,7 +124,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case detailStatsErrMsg: m.statsLoading = false m.statusMsg = "stats error: " + msg.err.Error() - m.mode = modeDetail + m.mode = modeDashboard return m, clearStatusCmd() // ── Schedule messages ───────────────────────────────────────────────── @@ -178,22 +183,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// refreshActiveSection reloads whatever the auto-refresh tick should keep fresh. +// Detail views reload too — an incident someone else acknowledged should stop +// looking unclaimed while you are staring at it. +func (m Model) refreshActiveSection() tea.Cmd { + switch m.mode { + case modeIncidentDetail: + return fetchIncidentDetailCmd(m.client, m.selectedIncident.ID) + case modeAlertDetail: + return fetchAlertDetailCmd(m.client, m.selectedAlert.ID) + case modeDashboard: + // fall through to the section refresh below + default: + // Modal states (compose, pickers, confirmations) are left alone: a + // refresh underneath a prompt would move the ground under the user. + return nil + } + + switch m.activeSection { + case sectionIncidents: + return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client)) + case sectionAlerts: + return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client)) + case sectionArchived: + return fetchArchivedIncidentsCmd(m.client) + case sectionSchedule: + return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) + case sectionUsers: + return fetchUsersCmd(m.client) + } + return nil +} + // routeKey passes the key to the active component then to our handler. func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch m.mode { - case modeDetail: + case modeIncidentDetail, modeAlertDetail: var vpCmd tea.Cmd m.detailViewport, vpCmd = m.detailViewport.Update(msg) m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(vpCmd, ourCmd) - case modeConfirmDelete: + case modeConfirm: // No component to scroll; just handle y/n. return m.handleKey(msg) - case modeComment: + case modeNote: var inputCmd tea.Cmd - m.commentInput, inputCmd = m.commentInput.Update(msg) + m.noteInput, inputCmd = m.noteInput.Update(msg) + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(inputCmd, ourCmd) + + case modeSnooze: + var inputCmd tea.Cmd + m.snoozeInput, inputCmd = m.snoozeInput.Update(msg) m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(inputCmd, ourCmd) @@ -203,7 +246,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { m2, ourCmd := m.handleKey(msg) return m2, tea.Batch(vpCmd, ourCmd) - case modeScheduleUserPicker: + case modeUserPicker: var tableCmd tea.Cmd m.userPickerTable, tableCmd = m.userPickerTable.Update(msg) m2, ourCmd := m.handleKey(msg) @@ -233,6 +276,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { default: // modeDashboard if m.connected { switch m.activeSection { + case sectionIncidents: + var tableCmd tea.Cmd + m.incidentTable, tableCmd = m.incidentTable.Update(msg) + m2, ourCmd := m.handleKey(msg) + return m2, tea.Batch(tableCmd, ourCmd) case sectionAlerts: var tableCmd tea.Cmd m.alertTable, tableCmd = m.alertTable.Update(msg) @@ -261,15 +309,19 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch m.mode { - case modeDetail: - return m.handleDetailKey(msg) - case modeComment: - return m.handleCommentKey(msg) - case modeConfirmDelete: + case modeIncidentDetail: + return m.handleIncidentDetailKey(msg) + case modeAlertDetail: + return m.handleAlertDetailKey(msg) + case modeNote: + return m.handleNoteKey(msg) + case modeSnooze: + return m.handleSnoozeKey(msg) + case modeConfirm: return m.handleConfirmKey(msg) case modeStats: return m.handleStatsKey(msg) - case modeScheduleUserPicker: + case modeUserPicker: return m.handleUserPickerKey(msg) case modeUserCreate: return m.handleUserCreateKey(msg) @@ -294,82 +346,50 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, tea.Quit case "tab": - 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 - to := m.scheduleWindow.AddDate(0, 0, 6) - return m, fetchScheduleCmd(m.client, from, to) - } - if next == sectionUsers && len(m.users) == 0 { - m.usersLoading = true - return m, fetchUsersCmd(m.client) - } - return m, nil + m.activeSection = section((int(m.activeSection) + 1) % sectionCount) + return m, m.loadSectionIfEmpty() + + case "shift+tab": + m.activeSection = section((int(m.activeSection) + sectionCount - 1) % sectionCount) + return m, m.loadSectionIfEmpty() case "r": 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 - to := m.scheduleWindow.AddDate(0, 0, 6) - return m, fetchScheduleCmd(m.client, from, to) - } - if m.activeSection == sectionUsers { - m.usersLoading = true - return m, fetchUsersCmd(m.client) - } m.statusMsg = "Refreshing…" - return m, tea.Batch( - fetchAlertsCmd(m.client, m.filterStatus), - fetchStatsCmd(m.client), - clearStatusCmd(), - ) + return m, tea.Batch(m.refreshActiveSection(), clearStatusCmd()) case "f": - if m.activeSection != sectionAlerts || !m.connected { + if !m.connected { return m, nil } - switch m.filterStatus { - case "firing": - m.filterStatus = "resolved" - case "resolved": - m.filterStatus = "" - default: - m.filterStatus = "firing" + switch m.activeSection { + case sectionIncidents: + m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter) + m.loading = true + return m, fetchIncidentsCmd(m.client, m.incidentFilter) + case sectionAlerts: + m.alertFilter = nextFilter(alertFilters, m.alertFilter) + m.loading = true + return m, fetchAlertsCmd(m.client, m.alertFilter) } - m.loading = true - return m, fetchAlertsCmd(m.client, m.filterStatus) + return m, nil case "enter": - if m.activeSection == sectionAlerts && len(m.alerts) > 0 { - cursor := m.alertTable.Cursor() - if cursor >= 0 && cursor < len(m.alerts) { - m.selectedAlert = m.alerts[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) + switch m.activeSection { + case sectionIncidents: + if inc, ok := m.incidentAtCursor(); ok { + return m.openIncident(inc) } - } - 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 + case sectionArchived: + if i := m.archivedTable.Cursor(); i >= 0 && i < len(m.archivedIncidents) { + return m.openIncident(m.archivedIncidents[i]) + } + case sectionAlerts: + if i := m.alertTable.Cursor(); i >= 0 && i < len(m.alerts) { + m.selectedAlert = m.alerts[i] + m.mode = modeAlertDetail m.detailLoading = true m.detailViewport = viewport.New(m.width, m.detailViewportHeight()) return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID) @@ -379,18 +399,23 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { case "x": switch m.activeSection { - case sectionAlerts: - cursor := m.alertTable.Cursor() - if cursor < 0 || cursor >= len(m.alerts) { + case sectionIncidents: + inc, ok := m.incidentAtCursor() + if !ok { return m, nil } - return m, archiveAlertCmd(m.client, m.alerts[cursor].ID, m.filterStatus) + if inc.IsOpen() { + m.statusMsg = "resolve the incident before archiving it" + return m, clearStatusCmd() + } + return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) case sectionArchived: - cursor := m.archivedTable.Cursor() - if cursor < 0 || cursor >= len(m.archivedAlerts) { + i := m.archivedTable.Cursor() + if i < 0 || i >= len(m.archivedIncidents) { return m, nil } - return m, unarchiveAlertCmd(m.client, m.archivedAlerts[cursor].ID) + m.archivedLoading = true + return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID) } return m, nil @@ -399,9 +424,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { if m.activeSection == sectionSchedule { m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7) m.scheduleLoading = true - from := m.scheduleWindow - to := m.scheduleWindow.AddDate(0, 0, 6) - return m, fetchScheduleCmd(m.client, from, to) + return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } return m, nil @@ -409,9 +432,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { if m.activeSection == sectionSchedule { m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7) m.scheduleLoading = true - from := m.scheduleWindow - to := m.scheduleWindow.AddDate(0, 0, 6) - return m, fetchScheduleCmd(m.client, from, to) + return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } return m, nil @@ -420,26 +441,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } m.pickerAssignWeek = false - m.mode = modeScheduleUserPicker - if len(m.users) == 0 { - m.usersLoading = true - return m, fetchUsersCmd(m.client) - } - m.rebuildUserPickerTable() - return m, nil + return m.openUserPicker(pickerSchedule) case "W": if m.activeSection != sectionSchedule || !m.connected { return m, nil } m.pickerAssignWeek = true - m.mode = modeScheduleUserPicker - if len(m.users) == 0 { - m.usersLoading = true - return m, fetchUsersCmd(m.client) - } - m.rebuildUserPickerTable() - return m, nil + return m.openUserPicker(pickerSchedule) case "d": switch m.activeSection { @@ -458,7 +467,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { } m.pendingDeleteEntry = day.entry m.confirmTarget = confirmDeleteScheduleEntry - m.mode = modeConfirmDelete + m.mode = modeConfirm case sectionUsers: if !m.connected || len(m.users) == 0 { return m, nil @@ -469,10 +478,16 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { } m.selectedUser = m.users[cursor] m.confirmTarget = confirmDeleteUser - m.mode = modeConfirmDelete + m.mode = modeConfirm } return m, nil + case "S": + if !m.connected { + return m, nil + } + return m.openStats() + case "n": if m.activeSection != sectionUsers || !m.connected { return m, nil @@ -501,9 +516,76 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil } -// ── Detail ──────────────────────────────────────────────────────────────── +// loadSectionIfEmpty fetches a section's data the first time it is opened. +func (m *Model) loadSectionIfEmpty() tea.Cmd { + switch m.activeSection { + case sectionAlerts: + if len(m.alerts) == 0 { + m.loading = true + return fetchAlertsCmd(m.client, m.alertFilter) + } + case sectionArchived: + if len(m.archivedIncidents) == 0 { + m.archivedLoading = true + return fetchArchivedIncidentsCmd(m.client) + } + case sectionSchedule: + if len(m.scheduleDays) == 0 { + m.scheduleLoading = true + return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) + } + case sectionUsers: + if len(m.users) == 0 { + m.usersLoading = true + return fetchUsersCmd(m.client) + } + } + return nil +} + +func (m Model) incidentAtCursor() (api.Incident, bool) { + i := m.incidentTable.Cursor() + if i < 0 || i >= len(m.incidents) { + return api.Incident{}, false + } + return m.incidents[i], true +} + +func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) { + m.selectedIncident = inc + m.mode = modeIncidentDetail + m.noteCursor = -1 + m.detailLoading = true + m.detailViewport = viewport.New(m.width, m.detailViewportHeight()) + return m, fetchIncidentDetailCmd(m.client, inc.ID) +} + +func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) { + m.pickerTarget = target + m.mode = modeUserPicker + if len(m.users) == 0 { + m.usersLoading = true + return m, fetchUsersCmd(m.client) + } + m.rebuildUserPickerTable() + return m, nil +} + +// nextFilter advances a filter cycle, wrapping at the end. +func nextFilter(cycle []string, current string) string { + for i, f := range cycle { + if f == current { + return cycle[(i+1)%len(cycle)] + } + } + return cycle[0] +} + +// ── Incident detail ─────────────────────────────────────────────────────── + +func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { + inc := m.selectedIncident -func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc", "backspace": m.mode = modeDashboard @@ -511,152 +593,245 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { return m, nil case "a": - if m.activeSection != sectionAlerts { - return m, nil + if !inc.IsOpen() { + return m.rejectClosed() } - if m.selectedAlert.AcknowledgedByID != nil { - m.statusMsg = "already acknowledged" + if inc.AcknowledgedByID != nil { + m.statusMsg = "already acknowledged by " + inc.AcknowledgedBy return m, clearStatusCmd() } - return m, acknowledgeCmd(m.client, m.selectedAlert.ID) + return m, acknowledgeIncidentCmd(m.client, inc.ID) case "A": - if m.activeSection != sectionAlerts { - return m, nil + if !inc.IsOpen() { + return m.rejectClosed() } - if m.selectedAlert.AcknowledgedByID == nil { + if inc.AcknowledgedByID == nil { m.statusMsg = "not acknowledged" return m, clearStatusCmd() } - return m, unacknowledgeCmd(m.client, m.selectedAlert.ID) + return m, unacknowledgeIncidentCmd(m.client, inc.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) + case "R": + if !inc.IsOpen() { + return m.rejectClosed() } + m.confirmTarget = confirmResolveIncident + m.mode = modeConfirm return m, nil + case "s": + if !inc.IsOpen() { + return m.rejectClosed() + } + return m.openUserPicker(pickerIncidentAssignee) + + case "z": + if !inc.IsOpen() { + return m.rejectClosed() + } + m.mode = modeSnooze + m.snoozeInput.Reset() + m.snoozeInput.Focus() + m.detailViewport.Height = m.detailViewportHeight() + return m, nil + + case "Z": + if !inc.IsOpen() { + return m.rejectClosed() + } + if !inc.IsSnoozed() { + m.statusMsg = "not snoozed" + return m, clearStatusCmd() + } + return m, unsnoozeIncidentCmd(m.client, inc.ID) + + case "x": + if inc.IsOpen() { + m.statusMsg = "resolve the incident before archiving it" + return m, clearStatusCmd() + } + if inc.ArchivedAt != nil { + m.archivedLoading = true + m.mode = modeDashboard + return m, unarchiveIncidentCmd(m.client, inc.ID) + } + m.mode = modeDashboard + return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter) + case "c": - m.mode = modeComment - m.commentInput.Reset() - m.commentInput.Focus() + m.mode = modeNote + m.noteInput.Reset() + m.noteInput.Focus() m.detailViewport.Height = m.detailViewportHeight() return m, nil case "d": - if m.commentCursor < 0 || m.commentCursor >= len(m.comments) { - m.statusMsg = "select a comment first with [ / ]" + notes := noteEvents(m.timeline) + if m.noteCursor < 0 || m.noteCursor >= len(notes) { + m.statusMsg = "select a note first with [ / ]" return m, clearStatusCmd() } - m.pendingDeleteID = m.comments[m.commentCursor].ID - m.confirmTarget = confirmDeleteComment - m.mode = modeConfirmDelete + m.pendingDeleteID = notes[m.noteCursor].ID + m.confirmTarget = confirmDeleteNote + m.mode = modeConfirm return m, nil - case "s": - m.statusMsg = "assign not yet supported by server" - return m, clearStatusCmd() - case "S": - m.mode = modeStats - m.statsLoading = true - m.statsViewport = viewport.New(m.width, m.height-5) - return m, fetchDetailStatsCmd(m.client) + return m.openStats() case "[": - if len(m.comments) == 0 { - return m, nil - } - if m.commentCursor <= 0 { - m.commentCursor = len(m.comments) - 1 - } else { - m.commentCursor-- - } - m.refreshDetailContent() - return m, nil + return m.moveNoteCursor(-1), nil case "]": - if len(m.comments) == 0 { - return m, nil - } - if m.commentCursor >= len(m.comments)-1 { - m.commentCursor = 0 - } else { - m.commentCursor++ - } - m.refreshDetailContent() - return m, nil + return m.moveNoteCursor(1), nil } return m, nil } -// ── Comment compose ─────────────────────────────────────────────────────── +// rejectClosed reports why an action did nothing on a resolved incident. The +// server answers 409 anyway; saying so up front is friendlier than a round trip. +func (m Model) rejectClosed() (Model, tea.Cmd) { + m.statusMsg = "incident is resolved — a new occurrence opens a new incident" + return m, clearStatusCmd() +} -func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) { +func (m Model) moveNoteCursor(delta int) Model { + notes := noteEvents(m.timeline) + if len(notes) == 0 { + return m + } + switch { + case m.noteCursor < 0: + if delta > 0 { + m.noteCursor = 0 + } else { + m.noteCursor = len(notes) - 1 + } + default: + m.noteCursor = (m.noteCursor + delta + len(notes)) % len(notes) + } + m.refreshDetailContent() + return m +} + +// ── Alert detail (read-only) ────────────────────────────────────────────── + +func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc", "backspace": + m.mode = modeDashboard + m.statusMsg = "" + return m, nil + + case "i": + // Jump to the incident this alert belongs to — the place where anything + // can actually be done about it. + if m.selectedAlert.IncidentID == nil { + m.statusMsg = "this alert has no incident" + return m, clearStatusCmd() + } + return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID}) + + case "S": + return m.openStats() + } + + return m, nil +} + +// ── Note compose ────────────────────────────────────────────────────────── + +func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": - m.mode = modeDetail - m.commentInput.Blur() + m.mode = modeIncidentDetail + m.noteInput.Blur() m.detailViewport.Height = m.detailViewportHeight() return m, nil case "enter": - content := strings.TrimSpace(m.commentInput.Value()) + content := strings.TrimSpace(m.noteInput.Value()) if content == "" { return m, nil } - m.mode = modeDetail - m.commentInput.Blur() + m.mode = modeIncidentDetail + m.noteInput.Blur() m.detailViewport.Height = m.detailViewportHeight() - return m, addCommentCmd(m.client, m.selectedAlert.ID, content) + return m, addNoteCmd(m.client, m.selectedIncident.ID, content) } return m, nil } -// ── Confirm delete ──────────────────────────────────────────────────────── +// ── Snooze ──────────────────────────────────────────────────────────────── + +func (m Model) handleSnoozeKey(msg tea.KeyMsg) (Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = modeIncidentDetail + m.snoozeInput.Blur() + m.detailViewport.Height = m.detailViewportHeight() + return m, nil + + case "enter": + duration := strings.TrimSpace(m.snoozeInput.Value()) + if duration == "" { + return m, nil + } + m.mode = modeIncidentDetail + m.snoozeInput.Blur() + m.detailViewport.Height = m.detailViewportHeight() + return m, snoozeIncidentCmd(m.client, m.selectedIncident.ID, duration) + } + + return m, nil +} + +// ── Confirm ─────────────────────────────────────────────────────────────── func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) { - switch strings.ToLower(msg.String()) { - case "y": + if strings.ToLower(msg.String()) != "y" { switch m.confirmTarget { - case confirmDeleteComment: - alertID := m.selectedAlert.ID - commentID := m.pendingDeleteID - m.mode = modeDetail - m.commentCursor = -1 - m.pendingDeleteID = 0 - return m, deleteCommentCmd(m.client, alertID, commentID) - case confirmDeleteScheduleEntry: - entry := m.pendingDeleteEntry - m.mode = modeDashboard - m.pendingDeleteEntry = nil - m.scheduleLoading = true - from := m.scheduleWindow - to := m.scheduleWindow.AddDate(0, 0, 6) - return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to) - case confirmDeleteUser: - userID := m.selectedUser.ID - m.mode = modeDashboard - m.usersLoading = true - return m, deleteUserCmd(m.client, userID) - } - default: - switch m.confirmTarget { - case confirmDeleteComment: - m.mode = modeDetail - case confirmDeleteScheduleEntry: - m.mode = modeDashboard - case confirmDeleteUser: + case confirmDeleteNote, confirmResolveIncident: + m.mode = modeIncidentDetail + default: m.mode = modeDashboard } m.pendingDeleteID = 0 m.pendingDeleteEntry = nil + return m, nil } + + switch m.confirmTarget { + case confirmDeleteNote: + incidentID := m.selectedIncident.ID + eventID := m.pendingDeleteID + m.mode = modeIncidentDetail + m.noteCursor = -1 + m.pendingDeleteID = 0 + return m, deleteNoteCmd(m.client, incidentID, eventID) + + case confirmResolveIncident: + m.mode = modeIncidentDetail + return m, resolveIncidentCmd(m.client, m.selectedIncident.ID) + + case confirmDeleteScheduleEntry: + entry := m.pendingDeleteEntry + m.mode = modeDashboard + m.pendingDeleteEntry = nil + m.scheduleLoading = true + return m, deleteScheduleEntryCmd(m.client, entry.ID, + m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) + + case confirmDeleteUser: + userID := m.selectedUser.ID + m.mode = modeDashboard + m.usersLoading = true + return m, deleteUserCmd(m.client, userID) + } + return m, nil } @@ -664,26 +839,45 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) { func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) { if msg.String() == "esc" { - m.mode = modeDetail + m.mode = m.statsReturnMode return m, nil } return m, nil } +// openStats enters the statistics view, remembering where to go back to. +func (m Model) openStats() (Model, tea.Cmd) { + m.statsReturnMode = m.mode + m.mode = modeStats + m.statsLoading = true + m.statsViewport = viewport.New(m.width, m.height-5) + return m, fetchDetailStatsCmd(m.client) +} + // ── User picker ─────────────────────────────────────────────────────────── func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) { switch msg.String() { case "esc": - m.mode = modeDashboard + if m.pickerTarget == pickerIncidentAssignee { + m.mode = modeIncidentDetail + } else { + m.mode = modeDashboard + } return m, nil case "enter": cursor := m.userPickerTable.Cursor() - if cursor >= len(m.users) { + if cursor < 0 || cursor >= len(m.users) { return m, nil } user := m.users[cursor] + + if m.pickerTarget == pickerIncidentAssignee { + m.mode = modeIncidentDetail + return m, assignIncidentCmd(m.client, m.selectedIncident.ID, user.ID) + } + scheduleCursor := m.scheduleTable.Cursor() if scheduleCursor >= len(m.scheduleDays) { m.mode = modeDashboard @@ -703,11 +897,10 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) { } else { dates = []string{d.Format("2006-01-02")} } - from := m.scheduleWindow - to := m.scheduleWindow.AddDate(0, 0, 6) m.mode = modeDashboard m.scheduleLoading = true - return m, assignScheduleCmd(m.client, user.ID, dates, from, to) + return m, assignScheduleCmd(m.client, user.ID, dates, + m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6)) } return m, nil diff --git a/internal/tui/view.go b/internal/tui/view.go index 60671b6..67dc485 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", "Archived", "Schedule", "Users"} +var sectionNames = []string{"Incidents", "Alerts", "Archived", "Schedule", "Users"} func (m Model) View() string { if m.width == 0 { @@ -27,11 +27,7 @@ func (m Model) View() string { func (m Model) renderHeader() string { title := styleHeader.Render("terdut-tui") right := styleMuted.Render(m.serverURL) - gap := m.width - lipgloss.Width(title) - lipgloss.Width(right) - if gap < 0 { - gap = 0 - } - return title + strings.Repeat(" ", gap) + right + return spread(title, right, m.width) } func (m Model) renderTabs() string { @@ -57,13 +53,15 @@ func (m Model) renderBody() string { } switch m.mode { - case modeDetail: + case modeIncidentDetail, modeAlertDetail: return m.renderDetail() - case modeComment: - return m.renderCommentCompose() - case modeConfirmDelete: + case modeNote: + return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View()) + case modeSnooze: + return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View()) + case modeConfirm: switch m.confirmTarget { - case confirmDeleteComment: + case confirmDeleteNote, confirmResolveIncident: return m.renderDetail() case confirmDeleteUser: return m.renderUsers() @@ -72,7 +70,7 @@ func (m Model) renderBody() string { } case modeStats: return m.renderStats() - case modeScheduleUserPicker: + case modeUserPicker: return m.renderUserPicker() case modeUserCreate: return m.renderUserCreate() @@ -90,133 +88,103 @@ func (m Model) renderBody() string { } func (m Model) renderFooter() string { - switch m.mode { - case modeDetail: - 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") - } + withStatus := func(actions string) string { + rendered := styleFooter.Render(actions) if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + actions + return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered } - return "\n" + actions + return "\n" + rendered + } - case modeComment: + switch m.mode { + case modeIncidentDetail: + if !m.selectedIncident.IsOpen() { + return withStatus(" x·archive c·note [/]·select d·del S·stats esc·back") + } + return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del S·stats esc·back") + + case modeAlertDetail: + return withStatus(" i·open incident S·stats esc·back") + + case modeNote: return "\n" + styleFooter.Render(" enter·submit esc·cancel") - case modeConfirmDelete: - var deleteDesc string - switch m.confirmTarget { - case confirmDeleteComment: - if m.commentCursor >= 0 && m.commentCursor < len(m.comments) { - deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username) - } else { - deleteDesc = "comment" - } - case confirmDeleteScheduleEntry: - if m.pendingDeleteEntry != nil { - deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username) - } else { - deleteDesc = "schedule entry" - } - case confirmDeleteUser: - deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username) - } - return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc)) + case modeSnooze: + return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") + + case modeConfirm: + return "\n" + styleError.Render(" "+m.confirmPrompt()) case modeStats: - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back") - } - return "\n" + styleFooter.Render(" esc·back") + return withStatus(" esc·back") - case modeScheduleUserPicker: - mode := "day" - if m.pickerAssignWeek { - mode = "week" + case modeUserPicker: + if m.pickerTarget == pickerIncidentAssignee { + return withStatus(" j/k·navigate enter·assign incident esc·cancel") } - return "\n" + styleFooter.Render(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", mode)) + scope := "day" + if m.pickerAssignWeek { + scope = "week" + } + return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope)) case modeUserCreate: - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel") - } - return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel") + return withStatus(" tab·next field enter·create esc·cancel") case modeAPIKeyMenu: - return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back") + return withStatus(" n·new key r·revoke by ID esc·back") case modeAPIKeyCreate: - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back") - } - return "\n" + styleFooter.Render(" enter·create esc·back") + return withStatus(" enter·create esc·back") case modeAPIKeyReveal: - footer := " c·copy to clipboard esc·done" - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer) - } - return "\n" + styleFooter.Render(footer) + return withStatus(" c·copy to clipboard esc·done") case modeAPIKeyRevokeByID: - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back") - } - return "\n" + styleFooter.Render(" enter·revoke esc·back") + return withStatus(" 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 + switch m.activeSection { + case sectionIncidents: + return withStatus(" enter·detail x·archive f·filter S·stats r·refresh tab·section q·quit") + case sectionAlerts: + return withStatus(" enter·detail f·filter S·stats r·refresh tab·section q·quit") + case sectionArchived: + return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit") + case sectionSchedule: + return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit") + case sectionUsers: + return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit") } - 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 != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + actions - } - return "\n" + actions - } - if m.activeSection == sectionUsers { - actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit") - if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\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())) - if m.statusMsg != "" { - status := styleStatus.Render(m.statusMsg) - gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView) - if gap < 0 { - gap = 0 - } - 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" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) } } +func (m Model) confirmPrompt() string { + switch m.confirmTarget { + case confirmDeleteNote: + return "Delete this note? [y/N]" + case confirmResolveIncident: + // Manual resolution is terminal on the server, so say so before asking. + return "Resolve incident? This is final — a new occurrence opens a new incident. [y/N]" + case confirmDeleteScheduleEntry: + if m.pendingDeleteEntry != nil { + return fmt.Sprintf("Delete on-call for %s (%s)? [y/N]", + m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username) + } + return "Delete schedule entry? [y/N]" + case confirmDeleteUser: + return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username) + } + return "Are you sure? [y/N]" +} + // ── Dashboard ────────────────────────────────────────────────────────────── func (m Model) renderDashboard() string { switch m.activeSection { + case sectionIncidents: + return m.renderIncidents() case sectionAlerts: return m.renderAlerts() case sectionArchived: @@ -229,6 +197,90 @@ func (m Model) renderDashboard() string { return "" } +func (m Model) renderIncidents() string { + bar := m.renderIncidentStatsBar() + var content string + switch { + case m.loading && len(m.incidents) == 0: + content = styleMuted.Render(" Loading incidents…") + case len(m.incidents) == 0: + content = styleMuted.Render( + fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter))) + default: + content = m.incidentTable.View() + } + return lipgloss.JoinVertical(lipgloss.Left, bar, content) +} + +func (m Model) renderAlerts() string { + bar := m.renderAlertStatsBar() + var content string + switch { + case m.loading && len(m.alerts) == 0: + content = styleMuted.Render(" Loading alerts…") + case len(m.alerts) == 0: + content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) + default: + content = m.alertTable.View() + } + return lipgloss.JoinVertical(lipgloss.Left, bar, content) +} + +func (m Model) renderArchived() string { + if m.archivedLoading { + return "\n" + styleMuted.Render(" Loading archived incidents…") + } + if len(m.archivedIncidents) == 0 { + return "\n" + styleMuted.Render(" No archived incidents.") + } + return "\n" + m.archivedTable.View() +} + +func (m Model) renderIncidentStatsBar() string { + var triggered, acked, resolved int + mtta, mttr := "—", "—" + if m.incidentStats != nil { + triggered = m.incidentStats.Triggered + acked = m.incidentStats.Acknowledged + resolved = m.incidentStats.Resolved + mtta = humanSeconds(m.incidentStats.MTTASeconds) + mttr = humanSeconds(m.incidentStats.MTTRSeconds) + } + left := fmt.Sprintf(" %s %s %s %s", + styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)), + styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)), + styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), + styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), + ) + right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) + return spread(left, right, m.width) +} + +func (m Model) renderAlertStatsBar() string { + total, firing, resolved := 0, 0, 0 + if m.alertStats != nil { + total = m.alertStats.Total + firing = m.alertStats.Firing + resolved = m.alertStats.Resolved + } + left := fmt.Sprintf(" Total: %d %s %s", + total, + styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), + styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), + ) + right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) + return spread(left, right, m.width) +} + +// spread pushes left and right to the edges of a width. +func spread(left, right string, width int) string { + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + return left + strings.Repeat(" ", gap) + right +} + // ── Schedule ─────────────────────────────────────────────────────────────── func (m Model) renderSchedule() string { @@ -236,7 +288,6 @@ func (m Model) renderSchedule() string { return "\n" + styleMuted.Render(" Loading schedule…") } - // On-call header var onCallLine string if m.currentOnCall != nil { onCallLine = fmt.Sprintf(" On-call today: %s", @@ -245,17 +296,12 @@ func (m Model) renderSchedule() string { onCallLine = styleMuted.Render(" On-call today: nobody scheduled") } - // Window label from := m.scheduleWindow to := m.scheduleWindow.AddDate(0, 0, 6) windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", from.Format("Jan 02"), to.Format("Jan 02, 2006"))) - gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel) - if gap < 0 { - gap = 0 - } - header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n" + header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" return header + m.scheduleTable.View() } @@ -264,6 +310,12 @@ func (m Model) renderUserPicker() string { return "\n" + styleMuted.Render(" Loading users…") } + if m.pickerTarget == pickerIncidentAssignee { + header := fmt.Sprintf("\n Assign %s to:\n\n", + styleBold.Render(m.selectedIncident.Title)) + return header + m.userPickerTable.View() + } + var scope string cursor := m.scheduleTable.Cursor() if cursor >= 0 && cursor < len(m.scheduleDays) { @@ -278,85 +330,30 @@ func (m Model) renderUserPicker() string { _, week := d.ISOWeek() scope = fmt.Sprintf("week W%02d (%s–%s)", week, monday.Format("Jan 02"), sunday.Format("Jan 02")) + } else if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") { + scope = "Today (" + d.Format("Mon") + ")" } else { - if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") { - scope = "Today (" + d.Format("Mon") + ")" - } else { - scope = d.Format("Jan 02 (Mon)") - } + scope = d.Format("Jan 02 (Mon)") } } - header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", - styleBold.Render(scope)) + header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope)) return header + m.userPickerTable.View() } -func (m Model) renderAlerts() string { - statsBar := m.renderStatsBar() - var content string - if m.loading && len(m.alerts) == 0 { - content = styleMuted.Render(" Loading alerts…") - } else if len(m.alerts) == 0 { - label := m.filterStatus - if label == "" { - label = "all" - } - content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label)) - } else { - content = m.alertTable.View() - } - 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 { - total = m.stats.Total - firing = m.stats.Firing - resolved = m.stats.Resolved - } - filterLabel := m.filterStatus - if filterLabel == "" { - filterLabel = "all" - } - left := fmt.Sprintf(" Total: %d %s %s", - total, - styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), - styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), - ) - right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel)) - gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - if gap < 0 { - gap = 0 - } - return left + strings.Repeat(" ", gap) + right -} - // ── Detail ───────────────────────────────────────────────────────────────── func (m Model) renderDetail() string { if m.detailLoading { - return "\n" + styleMuted.Render(" Loading alert details…") + return "\n" + styleMuted.Render(" Loading…") } return m.detailViewport.View() } -func (m Model) renderCommentCompose() string { - vp := m.detailViewport.View() +// renderPrompt puts an input line under the detail pane. +func (m Model) renderPrompt(prompt string) string { sep := styleMuted.Render(strings.Repeat("─", m.width)) - prompt := styleHeader.Render("Comment: ") + m.commentInput.View() - return vp + "\n" + sep + "\n" + prompt + return m.detailViewport.View() + "\n" + sep + "\n" + prompt } // ── Stats ────────────────────────────────────────────────────────────────── @@ -370,12 +367,191 @@ func (m Model) renderStats() string { // ── Content builders ─────────────────────────────────────────────────────── -func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string { +func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { + now := time.Now() + var b strings.Builder + contentW := width - 4 + + // Title + status header + title := styleAlertName.Render(inc.Title) + status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc)) + if inc.Severity != "" { + status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity)) + } + gap := contentW - lipgloss.Width(title) - lipgloss.Width(status) + if gap < 1 { + gap = 1 + } + b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n") + + // Timing and ownership + b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n", + inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) + + if inc.AssignedTo != "" { + b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) + } else { + b.WriteString(styleMuted.Render(" Assigned: nobody\n")) + } + + if inc.AcknowledgedByID != nil { + ackAt := "" + if inc.AcknowledgedAt != nil { + ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") + } + b.WriteString(styleResolved.Render( + fmt.Sprintf(" Acked: %s%s\n", inc.AcknowledgedBy, ackAt))) + } else { + b.WriteString(styleMuted.Render(" Acked: not acknowledged\n")) + } + + if inc.IsSnoozed() { + b.WriteString(styleSnoozed.Render(fmt.Sprintf(" Snoozed: until %s (%s)\n", + inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) + } + + if inc.ResolvedAt != nil { + source := "" + if inc.ResolutionSource != nil { + source = " · " + *inc.ResolutionSource + } + b.WriteString(fmt.Sprintf(" Resolved: %s (%s)%s\n", + inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) + } + if inc.ArchivedAt != nil { + b.WriteString(styleMuted.Render(" Archived: " + + inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC") + "\n")) + } + b.WriteString("\n") + + // Group labels — the correlation Alertmanager applied. + if len(inc.GroupLabels) > 0 { + b.WriteString(divider("Grouped By", width)) + for _, k := range sortedKeys(inc.GroupLabels) { + b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24))) + } + b.WriteString("\n") + } + + // Member alerts + b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) + if len(inc.Alerts) == 0 { + b.WriteString(styleMuted.Render(" No alerts.\n")) + } else { + for _, a := range inc.Alerts { + marker := styleFiring.Render("●") + if a.Status != "firing" { + marker = styleResolved.Render("✓") + } + instance := a.Labels["instance"] + if instance == "" { + instance = a.Fingerprint + } + b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n", + marker, truncate(a.Name, 28), truncate(instance, 26), + styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) + } + } + b.WriteString("\n") + + // Timeline — the only history the server keeps. + notes := noteEvents(timeline) + b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) + if len(timeline) == 0 { + b.WriteString(styleMuted.Render(" Nothing recorded yet.\n")) + } else { + noteIndex := 0 + for _, e := range timeline { + when := styleMuted.Render(humanAgo(now, e.CreatedAt)) + if e.Type != api.EventNote { + b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) + continue + } + marker := " " + author := styleBold.Render(e.Username) + if noteIndex == cursor { + marker = styleSelected.Render("> ") + author = styleSelected.Render(e.Username) + } + b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) + b.WriteString(" " + e.Detail + "\n") + noteIndex++ + } + } + + return b.String() +} + +// incidentStatusLabel is the headline badge for an incident. +func incidentStatusLabel(inc api.Incident) string { + switch inc.Status { + case api.StatusTriggered: + if inc.IsSnoozed() { + return "● TRIGGERED (snoozed)" + } + return "● TRIGGERED" + case api.StatusAcknowledged: + if inc.IsSnoozed() { + return "◐ ACKNOWLEDGED (snoozed)" + } + return "◐ ACKNOWLEDGED" + case api.StatusResolved: + return "✓ RESOLVED" + default: + return strings.ToUpper(inc.Status) + } +} + +// eventLabel renders one timeline entry as a sentence. Unrecognised types fall +// back to their raw name rather than vanishing — the server may add more. +func eventLabel(e api.IncidentEvent) string { + who := e.Username + switch e.Type { + case api.EventTriggered: + return " Incident opened" + case api.EventAlertAdded: + if e.AlertID != nil { + return fmt.Sprintf(" Alert #%d joined", *e.AlertID) + } + return " Alert joined" + case api.EventAlertResolved: + if e.AlertID != nil { + return fmt.Sprintf(" Alert #%d resolved", *e.AlertID) + } + return " Alert resolved" + case api.EventAcknowledged: + return " Acknowledged by " + who + case api.EventUnacknowledged: + return " Acknowledgement cleared by " + who + case api.EventAssigned: + // On an assigned event the user is the assignee, not the actor. + return " Assigned to " + who + case api.EventSnoozed: + if e.Detail != "" { + return " Snoozed until " + e.Detail + } + return " Snoozed" + case api.EventUnsnoozed: + return " Snooze cleared by " + who + case api.EventResolved: + if who != "" { + return " Resolved by " + who + } + return " Resolved (all alerts stopped firing)" + default: + label := " " + e.Type + if e.Detail != "" { + label += " · " + e.Detail + } + return label + } +} + +func buildAlertDetailContent(alert api.Alert, width int) string { now := time.Now() var b strings.Builder contentW := width - 4 - // Name + status header name := styleAlertName.Render(alert.Name) var statusStr string if alert.Status == "firing" { @@ -387,13 +563,12 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i } statusStr = styleResolved.Render(label) } - nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) - if nameGap < 1 { - nameGap = 1 + gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) + if gap < 1 { + gap = 1 } - b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n") + b.WriteString("\n " + name + strings.Repeat(" ", gap) + statusStr + "\n\n") - // Timeline 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", @@ -402,74 +577,42 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i 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", truncate(alert.GeneratorURL, contentW-12))) + } + if alert.IncidentID != nil { + b.WriteString(fmt.Sprintf(" Incident: %s %s\n", + styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), + styleMuted.Render("press i to open it"))) + } else { + b.WriteString(styleMuted.Render(" Incident: none\n")) } b.WriteString("\n") - // Labels if len(alert.Labels) > 0 { b.WriteString(divider("Labels", width)) for _, k := range sortedKeys(alert.Labels) { - v := alert.Labels[k] - if len(v) > contentW-24 { - v = v[:contentW-27] + "…" - } - b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v)) + b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24))) } b.WriteString("\n") } - // Annotations if len(alert.Annotations) > 0 { b.WriteString(divider("Annotations", width)) for _, k := range sortedKeys(alert.Annotations) { - v := alert.Annotations[k] - if len(v) > contentW-24 { - v = v[:contentW-27] + "…" - } - b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v)) + b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24))) } b.WriteString("\n") } - // Acknowledgement - b.WriteString(divider("Acknowledgement", width)) - if alert.AcknowledgedByID != nil { - ackAt := "" - if alert.AcknowledgedAt != nil { - ackAt = " at " + alert.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") - } - b.WriteString(styleResolved.Render(fmt.Sprintf(" ✓ Acknowledged by %s%s\n", alert.AcknowledgedBy, ackAt))) - } else { - b.WriteString(styleMuted.Render(" Not acknowledged\n")) - } - b.WriteString("\n") - - // Comments - b.WriteString(divider(fmt.Sprintf("Comments (%d)", len(comments)), width)) - if len(comments) == 0 { - b.WriteString(styleMuted.Render(" No comments yet.\n")) - } else { - for i, c := range comments { - prefix := " " - authorLine := fmt.Sprintf("%s%s • %s", prefix, styleBold.Render(c.Username), humanAgo(now, c.CreatedAt)) - if i == cursor { - authorLine = styleSelected.Render("> ") + styleBold.Render(c.Username) + - styleMuted.Render(fmt.Sprintf(" • %s", humanAgo(now, c.CreatedAt))) - } - b.WriteString(authorLine + "\n") - b.WriteString(" " + c.Content + "\n\n") - } - } + // Alerts carry no workflow state: it all lives on the incident. + b.WriteString(divider("", width)) + b.WriteString(styleMuted.Render( + " Alerts are read-only — acknowledge, assign, note and resolve on the incident.\n")) return b.String() } -func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { +func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { barWidth := width/2 - 10 if barWidth < 8 { barWidth = 8 @@ -481,7 +624,29 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da var b strings.Builder b.WriteString("\n") - // Top alerts + // Response times first: they are what a rota is actually judged on. + b.WriteString(divider("Incident Response", width)) + if incidents == nil { + b.WriteString(styleMuted.Render(" No data.\n")) + } else { + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", + styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered", + styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged", + styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved", + styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge", + styleBold.Render(humanSeconds(incidents.MTTASeconds)))) + b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", + styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) + if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { + b.WriteString(styleMuted.Render(" (— means nothing has been acknowledged or resolved yet)\n")) + } + } + b.WriteString("\n") + b.WriteString(divider("Top Alerts", width)) if len(top) == 0 { b.WriteString(styleMuted.Render(" No data.\n")) @@ -494,7 +659,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da } b.WriteString("\n") - // By hour b.WriteString(divider("Alerts by Hour (UTC)", width)) if len(byHour) > 0 { maxCount := 0 @@ -512,7 +676,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da } b.WriteString("\n") - // By day b.WriteString(divider("Alerts by Day", width)) if len(byDay) > 0 { maxCount := 0 @@ -602,7 +765,10 @@ func (m Model) renderAPIKeyRevokeByID() string { // ── Helpers ──────────────────────────────────────────────────────────────── func divider(title string, width int) string { - prefix := "── " + title + " " + prefix := "── " + if title != "" { + prefix += title + " " + } remaining := width - len(prefix) - 2 if remaining > 0 { prefix += strings.Repeat("─", remaining) @@ -631,6 +797,9 @@ func renderBarWidth(count, maxCount, maxWidth int) int { } func truncate(s string, max int) string { + if max < 1 { + return "" + } if len(s) <= max { return s }