1 Commits

Author SHA1 Message Date
Niklas Ye 1140d773f8 feat!: incidents as the primary object
Release / build (amd64, darwin) (push) Failing after 9s
Release / build (arm64, darwin) (push) Failing after 10s
Release / build (amd64, linux) (push) Failing after 10s
Release / release (push) Has been skipped
Release / build (arm64, linux) (push) Failing after 11s
terdut-server v0.4.0 splits the alerts row into two objects, and the
endpoints this client drove for acknowledgement, comments and archiving
are gone. Pointing the same screens at the new paths would have missed
the point of the split: alerts are now Alertmanager's record, read-only
and carrying no human state, while the incident is the thing anyone
actually works on.

Incidents lead the section list and are what the client opens on. The
queue shows severity, status, assignee and age, and the detail view adds
what only exists server-side now: the group labels Alertmanager
correlated on, the member alerts, and an append-only timeline where
system events and notes are interleaved. That timeline is the whole
history the server keeps — alert rows are still mutated in place — so
rendering it in order matters more than styling it.

Actions all move onto the incident: a/A acknowledge, s assign, z/Z
snooze, c note, d delete note, x archive, R resolve.

Two of those need care rather than a keybinding:

  - R, not r, resolves, and it asks first. The server treats a manual
    resolve as terminal: a later occurrence opens a new incident instead
    of reopening this one, and an alert that never stops firing leaves
    the incident closed for good. A stray keypress is not recoverable,
    so the prompt says what it means.
  - x refuses on an open incident rather than archiving it, since
    archiving unresolved work only hides it. Snooze is offered as the
    "not now" answer, and the client treats a snoozed_until in the past
    as not snoozed, matching the server, which sweeps nothing.

Statistics lead with MTTA and MTTR, neither of which was computable
before. The server sends null until something has actually been
acknowledged or resolved, and that renders as — rather than 0: no data
is a different claim from instant.

Alerts keep a tab of their own as the raw feed — useful for asking what
Alertmanager is really sending — with an Incident column replacing Ack
By, and i in the detail view jumping to the incident where something can
be done about it. Archived now holds archived incidents; archiving an
alert is server-side housekeeping and no longer a user action.

BREAKING CHANGE: requires terdut-server v0.4.0 or later. Against an
older server every incident request 404s. Use terdut-tui v0.3.x with
servers before v0.4.0.
2026-07-30 21:54:54 +02:00
8 changed files with 1622 additions and 731 deletions
+20 -1
View File
@@ -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 |
<!-- graymatter:instructions:begin — managed by `graymatter init`; edits inside this block are overwritten -->
## Memory (GrayMatter)
+77 -5
View File
@@ -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 |
+146 -34
View File
@@ -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)
+100 -10
View File
@@ -2,6 +2,9 @@ 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"`
@@ -13,30 +16,117 @@ type Alert struct {
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"`
// 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
// inferred it after the alert stopped being refreshed.
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 {
+365 -183
View File
@@ -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
@@ -112,29 +157,41 @@ type Model struct {
loading bool
err error
statusMsg string
incidentStats *api.IncidentStats
alertStats *api.AlertStats
// Incidents
incidents []api.Incident
incidentFilter string
incidentTable table.Model
// Alerts (read-only feed)
alerts []api.Alert
stats *api.AlertStats
filterStatus string
alertFilter string
alertTable table.Model
// Archived alerts
archivedAlerts []api.Alert
// Archived incidents
archivedIncidents []api.Incident
archivedLoading bool
archivedTable table.Model
// Detail
selectedAlert api.Alert
comments []api.Comment
commentCursor int
// Incident detail
selectedIncident api.Incident
timeline []api.IncidentEvent
noteCursor int
detailLoading bool
detailViewport viewport.Model
// Comment compose
commentInput textinput.Model
// Alert detail
selectedAlert api.Alert
// Confirm delete
// Note compose & snooze
noteInput textinput.Model
snoozeInput textinput.Model
// Confirm
confirmTarget confirmTarget
pendingDeleteID int64 // comment ID
pendingDeleteID int64 // note event ID
pendingDeleteEntry *api.ScheduleEntry
// Stats
@@ -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}
}
}
+55 -1
View File
@@ -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
}
}
+420 -227
View File
@@ -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, fetchAlertsCmd(m.client, m.filterStatus)
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)
}
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)
}
case sectionArchived:
if i := m.archivedTable.Cursor(); i >= 0 && i < len(m.archivedIncidents) {
return m.openIncident(m.archivedIncidents[i])
}
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 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
case confirmDeleteNote, confirmResolveIncident:
m.mode = modeIncidentDetail
default:
m.mode = modeDashboard
}
m.pendingDeleteID = 0
return m, deleteCommentCmd(m.client, alertID, commentID)
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
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6)
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
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)
}
default:
switch m.confirmTarget {
case confirmDeleteComment:
m.mode = modeDetail
case confirmDeleteScheduleEntry:
m.mode = modeDashboard
case confirmDeleteUser:
m.mode = modeDashboard
}
m.pendingDeleteID = 0
m.pendingDeleteEntry = nil
}
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":
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
+402 -233
View File
@@ -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" + rendered
}
return "\n" + actions
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
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")
}
return "\n" + actions
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
}
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
}
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 "\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 "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") {
} 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)")
}
}
}
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", truncate(alert.GeneratorURL, contentW-12)))
}
b.WriteString(fmt.Sprintf(" Source: %s\n", url))
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
}