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
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.
This commit is contained in:
+377
-195
@@ -18,21 +18,27 @@ import (
|
||||
type section int
|
||||
|
||||
const (
|
||||
sectionAlerts section = iota
|
||||
// Incidents lead: they are the work. Alerts is the raw feed underneath.
|
||||
sectionIncidents section = iota
|
||||
sectionAlerts
|
||||
sectionArchived
|
||||
sectionSchedule
|
||||
sectionUsers
|
||||
|
||||
sectionCount = 5
|
||||
)
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
modeDashboard mode = iota
|
||||
modeDetail
|
||||
modeComment
|
||||
modeConfirmDelete
|
||||
modeIncidentDetail
|
||||
modeAlertDetail
|
||||
modeNote
|
||||
modeSnooze
|
||||
modeConfirm
|
||||
modeStats
|
||||
modeScheduleUserPicker
|
||||
modeUserPicker
|
||||
modeUserCreate
|
||||
modeAPIKeyMenu
|
||||
modeAPIKeyCreate
|
||||
@@ -43,31 +49,70 @@ const (
|
||||
type confirmTarget int
|
||||
|
||||
const (
|
||||
confirmDeleteComment confirmTarget = iota
|
||||
confirmDeleteNote confirmTarget = iota
|
||||
confirmResolveIncident
|
||||
confirmDeleteScheduleEntry
|
||||
confirmDeleteUser
|
||||
)
|
||||
|
||||
// pickerTarget says what the user picker is choosing a person for.
|
||||
type pickerTarget int
|
||||
|
||||
const (
|
||||
pickerSchedule pickerTarget = iota
|
||||
pickerIncidentAssignee
|
||||
)
|
||||
|
||||
// incidentFilters is the cycle the f key walks in the Incidents section. The
|
||||
// empty string is the server default: open, unsnoozed incidents — the queue.
|
||||
var incidentFilters = []string{"", api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved, "snoozed"}
|
||||
|
||||
// alertFilters is the equivalent cycle for the raw alert feed.
|
||||
var alertFilters = []string{"firing", "resolved", "", "archived"}
|
||||
|
||||
// incidentQuery translates a filter from the cycle into server query terms.
|
||||
func incidentQuery(filter string) (status string, snoozed bool) {
|
||||
if filter == "snoozed" {
|
||||
return "", true
|
||||
}
|
||||
return filter, false
|
||||
}
|
||||
|
||||
// filterLabel renders a filter for the status bar.
|
||||
func filterLabel(filter string) string {
|
||||
if filter == "" {
|
||||
return "open"
|
||||
}
|
||||
return filter
|
||||
}
|
||||
|
||||
// ── Messages ───────────────────────────────────────────────────────────────
|
||||
|
||||
// dashboard
|
||||
type connectedMsg struct{}
|
||||
type connectErrMsg struct{ err error }
|
||||
type incidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type archivedIncidentsFetchedMsg struct{ incidents []api.Incident }
|
||||
type incidentActionDoneMsg struct {
|
||||
incidents []api.Incident
|
||||
status string
|
||||
}
|
||||
type alertsFetchedMsg struct{ alerts []api.Alert }
|
||||
type archivedAlertsFetchedMsg struct{ alerts []api.Alert }
|
||||
type alertArchivedMsg struct{ alerts []api.Alert }
|
||||
type alertUnarchivedMsg struct{ alerts []api.Alert }
|
||||
type statsFetchedMsg struct{ stats api.AlertStats }
|
||||
type statsFetchedMsg struct {
|
||||
incidents api.IncidentStats
|
||||
alerts api.AlertStats
|
||||
}
|
||||
type fetchDataErrMsg struct{ err error }
|
||||
type tickMsg time.Time
|
||||
type clearStatusMsg struct{}
|
||||
|
||||
// detail
|
||||
type alertDetailFetchedMsg struct {
|
||||
alert api.Alert
|
||||
comments []api.Comment
|
||||
type incidentDetailFetchedMsg struct {
|
||||
incident api.Incident
|
||||
timeline []api.IncidentEvent
|
||||
}
|
||||
type alertDetailErrMsg struct{ err error }
|
||||
type alertDetailFetchedMsg struct{ alert api.Alert }
|
||||
type detailErrMsg struct{ err error }
|
||||
type actionErrMsg struct{ err error }
|
||||
type detailStatsFetchedMsg struct {
|
||||
top []api.TopAlert
|
||||
@@ -108,34 +153,46 @@ type Model struct {
|
||||
height int
|
||||
|
||||
// Connection & dashboard
|
||||
connected bool
|
||||
loading bool
|
||||
err error
|
||||
statusMsg string
|
||||
alerts []api.Alert
|
||||
stats *api.AlertStats
|
||||
filterStatus string
|
||||
alertTable table.Model
|
||||
connected bool
|
||||
loading bool
|
||||
err error
|
||||
statusMsg string
|
||||
incidentStats *api.IncidentStats
|
||||
alertStats *api.AlertStats
|
||||
|
||||
// Archived alerts
|
||||
archivedAlerts []api.Alert
|
||||
archivedLoading bool
|
||||
archivedTable table.Model
|
||||
// Incidents
|
||||
incidents []api.Incident
|
||||
incidentFilter string
|
||||
incidentTable table.Model
|
||||
|
||||
// Detail
|
||||
selectedAlert api.Alert
|
||||
comments []api.Comment
|
||||
commentCursor int
|
||||
detailLoading bool
|
||||
detailViewport viewport.Model
|
||||
// Alerts (read-only feed)
|
||||
alerts []api.Alert
|
||||
alertFilter string
|
||||
alertTable table.Model
|
||||
|
||||
// Comment compose
|
||||
commentInput textinput.Model
|
||||
// Archived incidents
|
||||
archivedIncidents []api.Incident
|
||||
archivedLoading bool
|
||||
archivedTable table.Model
|
||||
|
||||
// Confirm delete
|
||||
confirmTarget confirmTarget
|
||||
pendingDeleteID int64 // comment ID
|
||||
pendingDeleteEntry *api.ScheduleEntry
|
||||
// Incident detail
|
||||
selectedIncident api.Incident
|
||||
timeline []api.IncidentEvent
|
||||
noteCursor int
|
||||
detailLoading bool
|
||||
detailViewport viewport.Model
|
||||
|
||||
// Alert detail
|
||||
selectedAlert api.Alert
|
||||
|
||||
// Note compose & snooze
|
||||
noteInput textinput.Model
|
||||
snoozeInput textinput.Model
|
||||
|
||||
// Confirm
|
||||
confirmTarget confirmTarget
|
||||
pendingDeleteID int64 // note event ID
|
||||
pendingDeleteEntry *api.ScheduleEntry
|
||||
|
||||
// Stats
|
||||
topAlerts []api.TopAlert
|
||||
@@ -143,6 +200,9 @@ type Model struct {
|
||||
dayStats []api.DayStat
|
||||
statsLoading bool
|
||||
statsViewport viewport.Model
|
||||
// statsReturnMode is where esc goes back to, since stats opens from both
|
||||
// the dashboard and an incident.
|
||||
statsReturnMode mode
|
||||
|
||||
// Schedule
|
||||
scheduleWindow time.Time
|
||||
@@ -152,10 +212,11 @@ type Model struct {
|
||||
scheduleLoading bool
|
||||
scheduleTable table.Model
|
||||
|
||||
// User picker (schedule assignment)
|
||||
// User picker (schedule assignment and incident assignee)
|
||||
users []api.User
|
||||
usersLoading bool
|
||||
userPickerTable table.Model
|
||||
pickerTarget pickerTarget
|
||||
pickerAssignWeek bool
|
||||
|
||||
// User management section
|
||||
@@ -174,6 +235,9 @@ type Model struct {
|
||||
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
||||
ts := defaultTableStyles()
|
||||
|
||||
incidentT := table.New(table.WithFocused(true))
|
||||
incidentT.SetStyles(ts)
|
||||
|
||||
alertT := table.New(table.WithFocused(true))
|
||||
alertT.SetStyles(ts)
|
||||
|
||||
@@ -189,9 +253,13 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
manageT := table.New(table.WithFocused(true))
|
||||
manageT.SetStyles(ts)
|
||||
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "type your comment…"
|
||||
ti.CharLimit = 1000
|
||||
noteIn := textinput.New()
|
||||
noteIn.Placeholder = "type your note…"
|
||||
noteIn.CharLimit = 1000
|
||||
|
||||
snoozeIn := textinput.New()
|
||||
snoozeIn.Placeholder = "duration, e.g. 2h or 30m"
|
||||
snoozeIn.CharLimit = 16
|
||||
|
||||
usernameIn := textinput.New()
|
||||
usernameIn.Placeholder = "username"
|
||||
@@ -221,14 +289,17 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
|
||||
client: client,
|
||||
serverURL: serverURL,
|
||||
refreshInterval: refreshInterval,
|
||||
activeSection: sectionAlerts,
|
||||
activeSection: sectionIncidents,
|
||||
mode: modeDashboard,
|
||||
loading: true,
|
||||
filterStatus: "firing",
|
||||
commentCursor: -1,
|
||||
incidentFilter: "",
|
||||
alertFilter: "firing",
|
||||
noteCursor: -1,
|
||||
incidentTable: incidentT,
|
||||
alertTable: alertT,
|
||||
archivedTable: archivedT,
|
||||
commentInput: ti,
|
||||
noteInput: noteIn,
|
||||
snoozeInput: snoozeIn,
|
||||
scheduleWindow: window,
|
||||
scheduleTable: schedT,
|
||||
userPickerTable: pickerT,
|
||||
@@ -257,34 +328,28 @@ func defaultTableStyles() table.Styles {
|
||||
return s
|
||||
}
|
||||
|
||||
func (m *Model) rebuildIncidentTable() {
|
||||
m.incidentTable.SetColumns(incidentColumns(m.width))
|
||||
m.incidentTable.SetRows(incidentRows(m.incidents))
|
||||
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildTable() {
|
||||
m.alertTable.SetColumns(alertColumns(m.width))
|
||||
m.alertTable.SetRows(alertRows(m.alerts))
|
||||
h := m.height - 8
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
m.alertTable.SetHeight(h)
|
||||
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildArchivedTable() {
|
||||
m.archivedTable.SetColumns(alertColumns(m.width))
|
||||
m.archivedTable.SetRows(alertRows(m.archivedAlerts))
|
||||
h := m.height - 8
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
m.archivedTable.SetHeight(h)
|
||||
m.archivedTable.SetColumns(incidentColumns(m.width))
|
||||
m.archivedTable.SetRows(incidentRows(m.archivedIncidents))
|
||||
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildScheduleTable() {
|
||||
m.scheduleTable.SetColumns(scheduleColumns(m.width))
|
||||
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays))
|
||||
h := m.height - 10
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
m.scheduleTable.SetHeight(h)
|
||||
m.scheduleTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildUserPickerTable() {
|
||||
@@ -294,11 +359,7 @@ func (m *Model) rebuildUserPickerTable() {
|
||||
rows[i] = table.Row{u.Username, u.Email}
|
||||
}
|
||||
m.userPickerTable.SetRows(rows)
|
||||
h := m.height - 10
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
m.userPickerTable.SetHeight(h)
|
||||
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
func (m *Model) rebuildUserManageTable() {
|
||||
@@ -308,27 +369,37 @@ func (m *Model) rebuildUserManageTable() {
|
||||
rows[i] = table.Row{u.Username, u.Email, u.CreatedAt.UTC().Format("2006-01-02")}
|
||||
}
|
||||
m.userManageTable.SetRows(rows)
|
||||
h := m.height - 10
|
||||
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
||||
}
|
||||
|
||||
func tableHeight(windowHeight, chrome int) int {
|
||||
h := windowHeight - chrome
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
m.userManageTable.SetHeight(h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Model) refreshDetailContent() {
|
||||
if m.width == 0 {
|
||||
return
|
||||
}
|
||||
m.detailViewport.SetContent(buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width))
|
||||
if m.mode == modeAlertDetail {
|
||||
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width))
|
||||
return
|
||||
}
|
||||
m.detailViewport.SetContent(
|
||||
buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width))
|
||||
}
|
||||
|
||||
func (m *Model) refreshStatsContent() {
|
||||
m.statsViewport.SetContent(buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width))
|
||||
m.statsViewport.SetContent(
|
||||
buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
|
||||
}
|
||||
|
||||
func (m Model) detailViewportHeight() int {
|
||||
h := m.height - 5
|
||||
if m.mode == modeComment {
|
||||
if m.mode == modeNote || m.mode == modeSnooze {
|
||||
h -= 2
|
||||
}
|
||||
if h < 1 {
|
||||
@@ -337,8 +408,40 @@ func (m Model) detailViewportHeight() int {
|
||||
return h
|
||||
}
|
||||
|
||||
// noteEvents filters a timeline down to the deletable entries, which is what
|
||||
// the [ and ] cursor walks.
|
||||
func noteEvents(timeline []api.IncidentEvent) []api.IncidentEvent {
|
||||
notes := make([]api.IncidentEvent, 0, len(timeline))
|
||||
for _, e := range timeline {
|
||||
if e.Type == api.EventNote {
|
||||
notes = append(notes, e)
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// ── Column definitions ─────────────────────────────────────────────────────
|
||||
|
||||
func incidentColumns(width int) []table.Column {
|
||||
const sevW, statusW, timeW = 9, 15, 12
|
||||
titleW := width/2 - 10
|
||||
if titleW < 20 {
|
||||
titleW = 20
|
||||
}
|
||||
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
||||
assigneeW := width - sevW - titleW - statusW - timeW - 10
|
||||
if assigneeW < 8 {
|
||||
assigneeW = 8
|
||||
}
|
||||
return []table.Column{
|
||||
{Title: "Sev", Width: sevW},
|
||||
{Title: "Incident", Width: titleW},
|
||||
{Title: "Status", Width: statusW},
|
||||
{Title: "Assignee", Width: assigneeW},
|
||||
{Title: "Triggered", Width: timeW},
|
||||
}
|
||||
}
|
||||
|
||||
func alertColumns(width int) []table.Column {
|
||||
const statusW, timeW = 10, 12
|
||||
nameW := width/2 - 14
|
||||
@@ -346,16 +449,16 @@ func alertColumns(width int) []table.Column {
|
||||
nameW = 20
|
||||
}
|
||||
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
||||
ackW := width - nameW - statusW - 2*timeW - 10
|
||||
if ackW < 8 {
|
||||
ackW = 8
|
||||
incW := width - nameW - statusW - 2*timeW - 10
|
||||
if incW < 8 {
|
||||
incW = 8
|
||||
}
|
||||
return []table.Column{
|
||||
{Title: "Name", Width: nameW},
|
||||
{Title: "Status", Width: statusW},
|
||||
{Title: "Started", Width: timeW},
|
||||
{Title: "Last Seen", Width: timeW},
|
||||
{Title: "Ack By", Width: ackW},
|
||||
{Title: "Incident", Width: incW},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,11 +502,38 @@ func userManageColumns(width int) []table.Column {
|
||||
|
||||
// ── Row builders ───────────────────────────────────────────────────────────
|
||||
|
||||
func incidentRows(incidents []api.Incident) []table.Row {
|
||||
now := time.Now()
|
||||
rows := make([]table.Row, len(incidents))
|
||||
for i, inc := range incidents {
|
||||
severity := inc.Severity
|
||||
if severity == "" {
|
||||
severity = "—"
|
||||
}
|
||||
// bubbles' table renders plain strings, so a snoozed incident is marked
|
||||
// in the status cell rather than styled.
|
||||
status := inc.Status
|
||||
if inc.IsSnoozed() {
|
||||
status += " (zzz)"
|
||||
}
|
||||
assignee := inc.AssignedTo
|
||||
if assignee == "" {
|
||||
assignee = "—"
|
||||
}
|
||||
rows[i] = table.Row{severity, inc.Title, status, assignee, humanAgo(now, inc.TriggeredAt)}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func alertRows(alerts []api.Alert) []table.Row {
|
||||
now := time.Now()
|
||||
rows := make([]table.Row, len(alerts))
|
||||
for i, a := range alerts {
|
||||
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), a.AcknowledgedBy}
|
||||
incident := "—"
|
||||
if a.IncidentID != nil {
|
||||
incident = fmt.Sprintf("#%d", *a.IncidentID)
|
||||
}
|
||||
rows[i] = table.Row{a.Name, a.Status, humanAgo(now, a.StartsAt), humanAgo(now, a.ReceivedAt), incident}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -457,28 +587,49 @@ func humanAgo(now, t time.Time) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return humanDuration(d) + " ago"
|
||||
}
|
||||
|
||||
// humanUntil renders a future deadline, used for snooze expiry.
|
||||
func humanUntil(now, t time.Time) string {
|
||||
d := t.Sub(now)
|
||||
if d <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
return "in " + humanDuration(d)
|
||||
}
|
||||
|
||||
func humanDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
return "moments"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if m == 0 {
|
||||
return fmt.Sprintf("%dh ago", h)
|
||||
return fmt.Sprintf("%dh", h)
|
||||
}
|
||||
return fmt.Sprintf("%dh %dm ago", h, m)
|
||||
return fmt.Sprintf("%dh %dm", h, m)
|
||||
default:
|
||||
days := int(d.Hours()) / 24
|
||||
h := int(d.Hours()) % 24
|
||||
if h == 0 {
|
||||
return fmt.Sprintf("%dd ago", days)
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
return fmt.Sprintf("%dd %dh ago", days, h)
|
||||
return fmt.Sprintf("%dd %dh", days, h)
|
||||
}
|
||||
}
|
||||
|
||||
// humanSeconds renders an MTTA/MTTR average.
|
||||
func humanSeconds(secs *float64) string {
|
||||
if secs == nil {
|
||||
return "—"
|
||||
}
|
||||
return humanDuration(time.Duration(*secs) * time.Second)
|
||||
}
|
||||
|
||||
// ── Commands ───────────────────────────────────────────────────────────────
|
||||
|
||||
func connectCmd(client *api.Client) tea.Cmd {
|
||||
@@ -490,9 +641,36 @@ func connectCmd(client *api.Client) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
|
||||
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
alerts, err := client.ListAlerts(status, false, 500)
|
||||
status, snoozed := incidentQuery(filter)
|
||||
incidents, err := client.ListIncidents(status, false, snoozed, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
return incidentsFetchedMsg{incidents}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchArchivedIncidentsCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Archived incidents are all resolved, so the status filter has to be
|
||||
// widened past the server's open-only default or nothing comes back.
|
||||
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
return archivedIncidentsFetchedMsg{incidents}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAlertsCmd(client *api.Client, filter string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
status, archived := filter, false
|
||||
if filter == "archived" {
|
||||
status, archived = "", true
|
||||
}
|
||||
alerts, err := client.ListAlerts(status, archived, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
@@ -500,49 +678,122 @@ func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func fetchArchivedAlertsCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
alerts, err := client.ListAlerts("", true, 500)
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
return archivedAlertsFetchedMsg{alerts}
|
||||
}
|
||||
}
|
||||
|
||||
func archiveAlertCmd(client *api.Client, alertID int64, filterStatus string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if _, err := client.ArchiveAlert(alertID); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
alerts, err := client.ListAlerts(filterStatus, false, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertArchivedMsg{alerts}
|
||||
}
|
||||
}
|
||||
|
||||
func unarchiveAlertCmd(client *api.Client, alertID int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.UnarchiveAlert(alertID); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
alerts, err := client.ListAlerts("", true, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertUnarchivedMsg{alerts}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStatsCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
stats, err := client.GetAlertStats()
|
||||
incidents, err := client.GetIncidentStats()
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
return statsFetchedMsg{*stats}
|
||||
alerts, err := client.GetAlertStats()
|
||||
if err != nil {
|
||||
return fetchDataErrMsg{err}
|
||||
}
|
||||
return statsFetchedMsg{incidents: *incidents, alerts: *alerts}
|
||||
}
|
||||
}
|
||||
|
||||
// incidentDetail reloads an incident and its timeline. Every detail-mode action
|
||||
// funnels through it so the view always reflects what the server just did.
|
||||
func incidentDetail(client *api.Client, id int64) tea.Msg {
|
||||
incident, err := client.GetIncident(id)
|
||||
if err != nil {
|
||||
return detailErrMsg{err}
|
||||
}
|
||||
timeline, err := client.GetIncidentTimeline(id)
|
||||
if err != nil {
|
||||
return detailErrMsg{err}
|
||||
}
|
||||
return incidentDetailFetchedMsg{incident: *incident, timeline: timeline}
|
||||
}
|
||||
|
||||
func fetchIncidentDetailCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return func() tea.Msg { return incidentDetail(client, id) }
|
||||
}
|
||||
|
||||
// incidentActionCmd performs an action then reloads the detail view, reporting
|
||||
// the server's error rather than a stale success.
|
||||
func incidentActionCmd(client *api.Client, id int64, action func() error) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := action(); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return incidentDetail(client, id)
|
||||
}
|
||||
}
|
||||
|
||||
func acknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error {
|
||||
_, err := client.AcknowledgeIncident(id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func unacknowledgeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error { return client.UnacknowledgeIncident(id) })
|
||||
}
|
||||
|
||||
func resolveIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error {
|
||||
_, err := client.ResolveIncident(id)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func assignIncidentCmd(client *api.Client, id, userID int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error {
|
||||
_, err := client.AssignIncident(id, userID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func snoozeIncidentCmd(client *api.Client, id int64, duration string) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error {
|
||||
_, err := client.SnoozeIncident(id, duration)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func unsnoozeIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error { return client.UnsnoozeIncident(id) })
|
||||
}
|
||||
|
||||
func addNoteCmd(client *api.Client, id int64, content string) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error {
|
||||
_, err := client.AddNote(id, content)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func deleteNoteCmd(client *api.Client, id, eventID int64) tea.Cmd {
|
||||
return incidentActionCmd(client, id, func() error { return client.DeleteNote(id, eventID) })
|
||||
}
|
||||
|
||||
// archiveIncidentCmd archives from the list view, so it reloads the list rather
|
||||
// than a detail pane.
|
||||
func archiveIncidentCmd(client *api.Client, id int64, filter string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if _, err := client.ArchiveIncident(id); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
status, snoozed := incidentQuery(filter)
|
||||
incidents, err := client.ListIncidents(status, false, snoozed, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return incidentActionDoneMsg{incidents: incidents, status: "Incident archived"}
|
||||
}
|
||||
}
|
||||
|
||||
func unarchiveIncidentCmd(client *api.Client, id int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.UnarchiveIncident(id); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
incidents, err := client.ListIncidents(api.StatusResolved, true, false, 500)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return archivedIncidentsFetchedMsg{incidents}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,78 +801,9 @@ func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
alert, err := client.GetAlert(alertID)
|
||||
if err != nil {
|
||||
return alertDetailErrMsg{err}
|
||||
return detailErrMsg{err}
|
||||
}
|
||||
comments, err := client.GetComments(alertID)
|
||||
if err != nil {
|
||||
return alertDetailErrMsg{err}
|
||||
}
|
||||
return alertDetailFetchedMsg{alert: *alert, comments: comments}
|
||||
}
|
||||
}
|
||||
|
||||
func acknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
alert, err := client.AcknowledgeAlert(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
comments, err := client.GetComments(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertDetailFetchedMsg{alert: *alert, comments: comments}
|
||||
}
|
||||
}
|
||||
|
||||
func unacknowledgeCmd(client *api.Client, alertID int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.UnacknowledgeAlert(alertID); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
alert, err := client.GetAlert(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
comments, err := client.GetComments(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertDetailFetchedMsg{alert: *alert, comments: comments}
|
||||
}
|
||||
}
|
||||
|
||||
func addCommentCmd(client *api.Client, alertID int64, content string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if _, err := client.AddComment(alertID, content); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
alert, err := client.GetAlert(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
comments, err := client.GetComments(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertDetailFetchedMsg{alert: *alert, comments: comments}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCommentCmd(client *api.Client, alertID, commentID int64) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := client.DeleteComment(alertID, commentID); err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
alert, err := client.GetAlert(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
comments, err := client.GetComments(alertID)
|
||||
if err != nil {
|
||||
return actionErrMsg{err}
|
||||
}
|
||||
return alertDetailFetchedMsg{alert: *alert, comments: comments}
|
||||
return alertDetailFetchedMsg{alert: *alert}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+55
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+429
-236
@@ -15,6 +15,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.rebuildIncidentTable()
|
||||
m.rebuildTable()
|
||||
m.rebuildArchivedTable()
|
||||
m.rebuildScheduleTable()
|
||||
@@ -35,7 +36,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = nil
|
||||
return m, tea.Batch(
|
||||
tickCmd(m.refreshInterval),
|
||||
fetchAlertsCmd(m.client, m.filterStatus),
|
||||
fetchIncidentsCmd(m.client, m.incidentFilter),
|
||||
fetchStatsCmd(m.client),
|
||||
)
|
||||
|
||||
@@ -44,62 +45,66 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
|
||||
case incidentsFetchedMsg:
|
||||
m.incidents = msg.incidents
|
||||
m.loading = false
|
||||
m.rebuildIncidentTable()
|
||||
return m, nil
|
||||
|
||||
case archivedIncidentsFetchedMsg:
|
||||
m.archivedIncidents = msg.incidents
|
||||
m.archivedLoading = false
|
||||
m.rebuildArchivedTable()
|
||||
m.mode = modeDashboard
|
||||
return m, nil
|
||||
|
||||
case incidentActionDoneMsg:
|
||||
m.incidents = msg.incidents
|
||||
m.loading = false
|
||||
m.rebuildIncidentTable()
|
||||
m.mode = modeDashboard
|
||||
m.statusMsg = msg.status
|
||||
return m, clearStatusCmd()
|
||||
|
||||
case alertsFetchedMsg:
|
||||
m.alerts = msg.alerts
|
||||
m.loading = false
|
||||
m.rebuildTable()
|
||||
return m, nil
|
||||
|
||||
case archivedAlertsFetchedMsg:
|
||||
m.archivedAlerts = msg.alerts
|
||||
m.archivedLoading = false
|
||||
m.rebuildArchivedTable()
|
||||
return m, nil
|
||||
|
||||
case alertArchivedMsg:
|
||||
m.alerts = msg.alerts
|
||||
m.loading = false
|
||||
m.rebuildTable()
|
||||
m.mode = modeDashboard
|
||||
m.statusMsg = "Alert archived"
|
||||
return m, clearStatusCmd()
|
||||
|
||||
case alertUnarchivedMsg:
|
||||
m.archivedAlerts = msg.alerts
|
||||
m.archivedLoading = false
|
||||
m.rebuildArchivedTable()
|
||||
m.mode = modeDashboard
|
||||
m.statusMsg = "Alert unarchived"
|
||||
return m, clearStatusCmd()
|
||||
|
||||
case statsFetchedMsg:
|
||||
m.stats = &msg.stats
|
||||
m.incidentStats = &msg.incidents
|
||||
m.alertStats = &msg.alerts
|
||||
return m, nil
|
||||
|
||||
case fetchDataErrMsg:
|
||||
m.loading = false
|
||||
m.archivedLoading = false
|
||||
m.statusMsg = "refresh error: " + msg.err.Error()
|
||||
return m, clearStatusCmd()
|
||||
|
||||
case tickMsg:
|
||||
return m, tea.Batch(
|
||||
tickCmd(m.refreshInterval),
|
||||
fetchAlertsCmd(m.client, m.filterStatus),
|
||||
fetchStatsCmd(m.client),
|
||||
)
|
||||
return m, tea.Batch(tickCmd(m.refreshInterval), m.refreshActiveSection())
|
||||
|
||||
// ── Detail messages ───────────────────────────────────────────────────
|
||||
|
||||
case alertDetailFetchedMsg:
|
||||
m.selectedAlert = msg.alert
|
||||
m.comments = msg.comments
|
||||
case incidentDetailFetchedMsg:
|
||||
m.selectedIncident = msg.incident
|
||||
m.timeline = msg.timeline
|
||||
m.detailLoading = false
|
||||
if m.commentCursor >= len(m.comments) {
|
||||
m.commentCursor = -1
|
||||
if m.noteCursor >= len(noteEvents(m.timeline)) {
|
||||
m.noteCursor = -1
|
||||
}
|
||||
m.refreshDetailContent()
|
||||
return m, nil
|
||||
|
||||
case alertDetailErrMsg:
|
||||
case alertDetailFetchedMsg:
|
||||
m.selectedAlert = msg.alert
|
||||
m.detailLoading = false
|
||||
m.refreshDetailContent()
|
||||
return m, nil
|
||||
|
||||
case detailErrMsg:
|
||||
m.detailLoading = false
|
||||
m.statusMsg = "error: " + msg.err.Error()
|
||||
return m, clearStatusCmd()
|
||||
@@ -119,7 +124,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case detailStatsErrMsg:
|
||||
m.statsLoading = false
|
||||
m.statusMsg = "stats error: " + msg.err.Error()
|
||||
m.mode = modeDetail
|
||||
m.mode = modeDashboard
|
||||
return m, clearStatusCmd()
|
||||
|
||||
// ── Schedule messages ─────────────────────────────────────────────────
|
||||
@@ -178,22 +183,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// refreshActiveSection reloads whatever the auto-refresh tick should keep fresh.
|
||||
// Detail views reload too — an incident someone else acknowledged should stop
|
||||
// looking unclaimed while you are staring at it.
|
||||
func (m Model) refreshActiveSection() tea.Cmd {
|
||||
switch m.mode {
|
||||
case modeIncidentDetail:
|
||||
return fetchIncidentDetailCmd(m.client, m.selectedIncident.ID)
|
||||
case modeAlertDetail:
|
||||
return fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
||||
case modeDashboard:
|
||||
// fall through to the section refresh below
|
||||
default:
|
||||
// Modal states (compose, pickers, confirmations) are left alone: a
|
||||
// refresh underneath a prompt would move the ground under the user.
|
||||
return nil
|
||||
}
|
||||
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return tea.Batch(fetchIncidentsCmd(m.client, m.incidentFilter), fetchStatsCmd(m.client))
|
||||
case sectionAlerts:
|
||||
return tea.Batch(fetchAlertsCmd(m.client, m.alertFilter), fetchStatsCmd(m.client))
|
||||
case sectionArchived:
|
||||
return fetchArchivedIncidentsCmd(m.client)
|
||||
case sectionSchedule:
|
||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
case sectionUsers:
|
||||
return fetchUsersCmd(m.client)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeKey passes the key to the active component then to our handler.
|
||||
func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
case modeDetail:
|
||||
case modeIncidentDetail, modeAlertDetail:
|
||||
var vpCmd tea.Cmd
|
||||
m.detailViewport, vpCmd = m.detailViewport.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(vpCmd, ourCmd)
|
||||
|
||||
case modeConfirmDelete:
|
||||
case modeConfirm:
|
||||
// No component to scroll; just handle y/n.
|
||||
return m.handleKey(msg)
|
||||
|
||||
case modeComment:
|
||||
case modeNote:
|
||||
var inputCmd tea.Cmd
|
||||
m.commentInput, inputCmd = m.commentInput.Update(msg)
|
||||
m.noteInput, inputCmd = m.noteInput.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(inputCmd, ourCmd)
|
||||
|
||||
case modeSnooze:
|
||||
var inputCmd tea.Cmd
|
||||
m.snoozeInput, inputCmd = m.snoozeInput.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(inputCmd, ourCmd)
|
||||
|
||||
@@ -203,7 +246,7 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(vpCmd, ourCmd)
|
||||
|
||||
case modeScheduleUserPicker:
|
||||
case modeUserPicker:
|
||||
var tableCmd tea.Cmd
|
||||
m.userPickerTable, tableCmd = m.userPickerTable.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
@@ -233,6 +276,11 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
default: // modeDashboard
|
||||
if m.connected {
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
var tableCmd tea.Cmd
|
||||
m.incidentTable, tableCmd = m.incidentTable.Update(msg)
|
||||
m2, ourCmd := m.handleKey(msg)
|
||||
return m2, tea.Batch(tableCmd, ourCmd)
|
||||
case sectionAlerts:
|
||||
var tableCmd tea.Cmd
|
||||
m.alertTable, tableCmd = m.alertTable.Update(msg)
|
||||
@@ -261,15 +309,19 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch m.mode {
|
||||
case modeDetail:
|
||||
return m.handleDetailKey(msg)
|
||||
case modeComment:
|
||||
return m.handleCommentKey(msg)
|
||||
case modeConfirmDelete:
|
||||
case modeIncidentDetail:
|
||||
return m.handleIncidentDetailKey(msg)
|
||||
case modeAlertDetail:
|
||||
return m.handleAlertDetailKey(msg)
|
||||
case modeNote:
|
||||
return m.handleNoteKey(msg)
|
||||
case modeSnooze:
|
||||
return m.handleSnoozeKey(msg)
|
||||
case modeConfirm:
|
||||
return m.handleConfirmKey(msg)
|
||||
case modeStats:
|
||||
return m.handleStatsKey(msg)
|
||||
case modeScheduleUserPicker:
|
||||
case modeUserPicker:
|
||||
return m.handleUserPickerKey(msg)
|
||||
case modeUserCreate:
|
||||
return m.handleUserCreateKey(msg)
|
||||
@@ -294,82 +346,50 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, tea.Quit
|
||||
|
||||
case "tab":
|
||||
next := section((int(m.activeSection) + 1) % 4)
|
||||
m.activeSection = next
|
||||
if next == sectionArchived && len(m.archivedAlerts) == 0 {
|
||||
m.archivedLoading = true
|
||||
return m, fetchArchivedAlertsCmd(m.client)
|
||||
}
|
||||
if next == sectionSchedule && len(m.scheduleDays) == 0 {
|
||||
m.scheduleLoading = true
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
return m, fetchScheduleCmd(m.client, from, to)
|
||||
}
|
||||
if next == sectionUsers && len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
}
|
||||
return m, nil
|
||||
m.activeSection = section((int(m.activeSection) + 1) % sectionCount)
|
||||
return m, m.loadSectionIfEmpty()
|
||||
|
||||
case "shift+tab":
|
||||
m.activeSection = section((int(m.activeSection) + sectionCount - 1) % sectionCount)
|
||||
return m, m.loadSectionIfEmpty()
|
||||
|
||||
case "r":
|
||||
if !m.connected {
|
||||
return m, connectCmd(m.client)
|
||||
}
|
||||
if m.activeSection == sectionArchived {
|
||||
m.archivedLoading = true
|
||||
return m, fetchArchivedAlertsCmd(m.client)
|
||||
}
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleLoading = true
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
return m, fetchScheduleCmd(m.client, from, to)
|
||||
}
|
||||
if m.activeSection == sectionUsers {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
}
|
||||
m.statusMsg = "Refreshing…"
|
||||
return m, tea.Batch(
|
||||
fetchAlertsCmd(m.client, m.filterStatus),
|
||||
fetchStatsCmd(m.client),
|
||||
clearStatusCmd(),
|
||||
)
|
||||
return m, tea.Batch(m.refreshActiveSection(), clearStatusCmd())
|
||||
|
||||
case "f":
|
||||
if m.activeSection != sectionAlerts || !m.connected {
|
||||
if !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
switch m.filterStatus {
|
||||
case "firing":
|
||||
m.filterStatus = "resolved"
|
||||
case "resolved":
|
||||
m.filterStatus = ""
|
||||
default:
|
||||
m.filterStatus = "firing"
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
m.incidentFilter = nextFilter(incidentFilters, m.incidentFilter)
|
||||
m.loading = true
|
||||
return m, fetchIncidentsCmd(m.client, m.incidentFilter)
|
||||
case sectionAlerts:
|
||||
m.alertFilter = nextFilter(alertFilters, m.alertFilter)
|
||||
m.loading = true
|
||||
return m, fetchAlertsCmd(m.client, m.alertFilter)
|
||||
}
|
||||
m.loading = true
|
||||
return m, fetchAlertsCmd(m.client, m.filterStatus)
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if m.activeSection == sectionAlerts && len(m.alerts) > 0 {
|
||||
cursor := m.alertTable.Cursor()
|
||||
if cursor >= 0 && cursor < len(m.alerts) {
|
||||
m.selectedAlert = m.alerts[cursor]
|
||||
m.mode = modeDetail
|
||||
m.commentCursor = -1
|
||||
m.detailLoading = true
|
||||
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
||||
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
if inc, ok := m.incidentAtCursor(); ok {
|
||||
return m.openIncident(inc)
|
||||
}
|
||||
}
|
||||
if m.activeSection == sectionArchived && len(m.archivedAlerts) > 0 {
|
||||
cursor := m.archivedTable.Cursor()
|
||||
if cursor >= 0 && cursor < len(m.archivedAlerts) {
|
||||
m.selectedAlert = m.archivedAlerts[cursor]
|
||||
m.mode = modeDetail
|
||||
m.commentCursor = -1
|
||||
case sectionArchived:
|
||||
if i := m.archivedTable.Cursor(); i >= 0 && i < len(m.archivedIncidents) {
|
||||
return m.openIncident(m.archivedIncidents[i])
|
||||
}
|
||||
case sectionAlerts:
|
||||
if i := m.alertTable.Cursor(); i >= 0 && i < len(m.alerts) {
|
||||
m.selectedAlert = m.alerts[i]
|
||||
m.mode = modeAlertDetail
|
||||
m.detailLoading = true
|
||||
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
||||
return m, fetchAlertDetailCmd(m.client, m.selectedAlert.ID)
|
||||
@@ -379,18 +399,23 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
|
||||
case "x":
|
||||
switch m.activeSection {
|
||||
case sectionAlerts:
|
||||
cursor := m.alertTable.Cursor()
|
||||
if cursor < 0 || cursor >= len(m.alerts) {
|
||||
case sectionIncidents:
|
||||
inc, ok := m.incidentAtCursor()
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
return m, archiveAlertCmd(m.client, m.alerts[cursor].ID, m.filterStatus)
|
||||
if inc.IsOpen() {
|
||||
m.statusMsg = "resolve the incident before archiving it"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
||||
case sectionArchived:
|
||||
cursor := m.archivedTable.Cursor()
|
||||
if cursor < 0 || cursor >= len(m.archivedAlerts) {
|
||||
i := m.archivedTable.Cursor()
|
||||
if i < 0 || i >= len(m.archivedIncidents) {
|
||||
return m, nil
|
||||
}
|
||||
return m, unarchiveAlertCmd(m.client, m.archivedAlerts[cursor].ID)
|
||||
m.archivedLoading = true
|
||||
return m, unarchiveIncidentCmd(m.client, m.archivedIncidents[i].ID)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -399,9 +424,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, -7)
|
||||
m.scheduleLoading = true
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
return m, fetchScheduleCmd(m.client, from, to)
|
||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -409,9 +432,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if m.activeSection == sectionSchedule {
|
||||
m.scheduleWindow = m.scheduleWindow.AddDate(0, 0, 7)
|
||||
m.scheduleLoading = true
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
return m, fetchScheduleCmd(m.client, from, to)
|
||||
return m, fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
@@ -420,26 +441,14 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerAssignWeek = false
|
||||
m.mode = modeScheduleUserPicker
|
||||
if len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
}
|
||||
m.rebuildUserPickerTable()
|
||||
return m, nil
|
||||
return m.openUserPicker(pickerSchedule)
|
||||
|
||||
case "W":
|
||||
if m.activeSection != sectionSchedule || !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
m.pickerAssignWeek = true
|
||||
m.mode = modeScheduleUserPicker
|
||||
if len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
}
|
||||
m.rebuildUserPickerTable()
|
||||
return m, nil
|
||||
return m.openUserPicker(pickerSchedule)
|
||||
|
||||
case "d":
|
||||
switch m.activeSection {
|
||||
@@ -458,7 +467,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
}
|
||||
m.pendingDeleteEntry = day.entry
|
||||
m.confirmTarget = confirmDeleteScheduleEntry
|
||||
m.mode = modeConfirmDelete
|
||||
m.mode = modeConfirm
|
||||
case sectionUsers:
|
||||
if !m.connected || len(m.users) == 0 {
|
||||
return m, nil
|
||||
@@ -469,10 +478,16 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
}
|
||||
m.selectedUser = m.users[cursor]
|
||||
m.confirmTarget = confirmDeleteUser
|
||||
m.mode = modeConfirmDelete
|
||||
m.mode = modeConfirm
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "S":
|
||||
if !m.connected {
|
||||
return m, nil
|
||||
}
|
||||
return m.openStats()
|
||||
|
||||
case "n":
|
||||
if m.activeSection != sectionUsers || !m.connected {
|
||||
return m, nil
|
||||
@@ -501,9 +516,76 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Detail ────────────────────────────────────────────────────────────────
|
||||
// loadSectionIfEmpty fetches a section's data the first time it is opened.
|
||||
func (m *Model) loadSectionIfEmpty() tea.Cmd {
|
||||
switch m.activeSection {
|
||||
case sectionAlerts:
|
||||
if len(m.alerts) == 0 {
|
||||
m.loading = true
|
||||
return fetchAlertsCmd(m.client, m.alertFilter)
|
||||
}
|
||||
case sectionArchived:
|
||||
if len(m.archivedIncidents) == 0 {
|
||||
m.archivedLoading = true
|
||||
return fetchArchivedIncidentsCmd(m.client)
|
||||
}
|
||||
case sectionSchedule:
|
||||
if len(m.scheduleDays) == 0 {
|
||||
m.scheduleLoading = true
|
||||
return fetchScheduleCmd(m.client, m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
case sectionUsers:
|
||||
if len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return fetchUsersCmd(m.client)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) incidentAtCursor() (api.Incident, bool) {
|
||||
i := m.incidentTable.Cursor()
|
||||
if i < 0 || i >= len(m.incidents) {
|
||||
return api.Incident{}, false
|
||||
}
|
||||
return m.incidents[i], true
|
||||
}
|
||||
|
||||
func (m Model) openIncident(inc api.Incident) (Model, tea.Cmd) {
|
||||
m.selectedIncident = inc
|
||||
m.mode = modeIncidentDetail
|
||||
m.noteCursor = -1
|
||||
m.detailLoading = true
|
||||
m.detailViewport = viewport.New(m.width, m.detailViewportHeight())
|
||||
return m, fetchIncidentDetailCmd(m.client, inc.ID)
|
||||
}
|
||||
|
||||
func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
|
||||
m.pickerTarget = target
|
||||
m.mode = modeUserPicker
|
||||
if len(m.users) == 0 {
|
||||
m.usersLoading = true
|
||||
return m, fetchUsersCmd(m.client)
|
||||
}
|
||||
m.rebuildUserPickerTable()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// nextFilter advances a filter cycle, wrapping at the end.
|
||||
func nextFilter(cycle []string, current string) string {
|
||||
for i, f := range cycle {
|
||||
if f == current {
|
||||
return cycle[(i+1)%len(cycle)]
|
||||
}
|
||||
}
|
||||
return cycle[0]
|
||||
}
|
||||
|
||||
// ── Incident detail ───────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleIncidentDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
inc := m.selectedIncident
|
||||
|
||||
func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "backspace":
|
||||
m.mode = modeDashboard
|
||||
@@ -511,152 +593,245 @@ func (m Model) handleDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case "a":
|
||||
if m.activeSection != sectionAlerts {
|
||||
return m, nil
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
if m.selectedAlert.AcknowledgedByID != nil {
|
||||
m.statusMsg = "already acknowledged"
|
||||
if inc.AcknowledgedByID != nil {
|
||||
m.statusMsg = "already acknowledged by " + inc.AcknowledgedBy
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m, acknowledgeCmd(m.client, m.selectedAlert.ID)
|
||||
return m, acknowledgeIncidentCmd(m.client, inc.ID)
|
||||
|
||||
case "A":
|
||||
if m.activeSection != sectionAlerts {
|
||||
return m, nil
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
if m.selectedAlert.AcknowledgedByID == nil {
|
||||
if inc.AcknowledgedByID == nil {
|
||||
m.statusMsg = "not acknowledged"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m, unacknowledgeCmd(m.client, m.selectedAlert.ID)
|
||||
return m, unacknowledgeIncidentCmd(m.client, inc.ID)
|
||||
|
||||
case "x":
|
||||
if m.activeSection == sectionAlerts {
|
||||
return m, archiveAlertCmd(m.client, m.selectedAlert.ID, m.filterStatus)
|
||||
}
|
||||
if m.activeSection == sectionArchived {
|
||||
return m, unarchiveAlertCmd(m.client, m.selectedAlert.ID)
|
||||
case "R":
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
m.confirmTarget = confirmResolveIncident
|
||||
m.mode = modeConfirm
|
||||
return m, nil
|
||||
|
||||
case "s":
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
return m.openUserPicker(pickerIncidentAssignee)
|
||||
|
||||
case "z":
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
m.mode = modeSnooze
|
||||
m.snoozeInput.Reset()
|
||||
m.snoozeInput.Focus()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, nil
|
||||
|
||||
case "Z":
|
||||
if !inc.IsOpen() {
|
||||
return m.rejectClosed()
|
||||
}
|
||||
if !inc.IsSnoozed() {
|
||||
m.statusMsg = "not snoozed"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m, unsnoozeIncidentCmd(m.client, inc.ID)
|
||||
|
||||
case "x":
|
||||
if inc.IsOpen() {
|
||||
m.statusMsg = "resolve the incident before archiving it"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
if inc.ArchivedAt != nil {
|
||||
m.archivedLoading = true
|
||||
m.mode = modeDashboard
|
||||
return m, unarchiveIncidentCmd(m.client, inc.ID)
|
||||
}
|
||||
m.mode = modeDashboard
|
||||
return m, archiveIncidentCmd(m.client, inc.ID, m.incidentFilter)
|
||||
|
||||
case "c":
|
||||
m.mode = modeComment
|
||||
m.commentInput.Reset()
|
||||
m.commentInput.Focus()
|
||||
m.mode = modeNote
|
||||
m.noteInput.Reset()
|
||||
m.noteInput.Focus()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, nil
|
||||
|
||||
case "d":
|
||||
if m.commentCursor < 0 || m.commentCursor >= len(m.comments) {
|
||||
m.statusMsg = "select a comment first with [ / ]"
|
||||
notes := noteEvents(m.timeline)
|
||||
if m.noteCursor < 0 || m.noteCursor >= len(notes) {
|
||||
m.statusMsg = "select a note first with [ / ]"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
m.pendingDeleteID = m.comments[m.commentCursor].ID
|
||||
m.confirmTarget = confirmDeleteComment
|
||||
m.mode = modeConfirmDelete
|
||||
m.pendingDeleteID = notes[m.noteCursor].ID
|
||||
m.confirmTarget = confirmDeleteNote
|
||||
m.mode = modeConfirm
|
||||
return m, nil
|
||||
|
||||
case "s":
|
||||
m.statusMsg = "assign not yet supported by server"
|
||||
return m, clearStatusCmd()
|
||||
|
||||
case "S":
|
||||
m.mode = modeStats
|
||||
m.statsLoading = true
|
||||
m.statsViewport = viewport.New(m.width, m.height-5)
|
||||
return m, fetchDetailStatsCmd(m.client)
|
||||
return m.openStats()
|
||||
|
||||
case "[":
|
||||
if len(m.comments) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
if m.commentCursor <= 0 {
|
||||
m.commentCursor = len(m.comments) - 1
|
||||
} else {
|
||||
m.commentCursor--
|
||||
}
|
||||
m.refreshDetailContent()
|
||||
return m, nil
|
||||
return m.moveNoteCursor(-1), nil
|
||||
|
||||
case "]":
|
||||
if len(m.comments) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
if m.commentCursor >= len(m.comments)-1 {
|
||||
m.commentCursor = 0
|
||||
} else {
|
||||
m.commentCursor++
|
||||
}
|
||||
m.refreshDetailContent()
|
||||
return m, nil
|
||||
return m.moveNoteCursor(1), nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Comment compose ───────────────────────────────────────────────────────
|
||||
// rejectClosed reports why an action did nothing on a resolved incident. The
|
||||
// server answers 409 anyway; saying so up front is friendlier than a round trip.
|
||||
func (m Model) rejectClosed() (Model, tea.Cmd) {
|
||||
m.statusMsg = "incident is resolved — a new occurrence opens a new incident"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
|
||||
func (m Model) handleCommentKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
func (m Model) moveNoteCursor(delta int) Model {
|
||||
notes := noteEvents(m.timeline)
|
||||
if len(notes) == 0 {
|
||||
return m
|
||||
}
|
||||
switch {
|
||||
case m.noteCursor < 0:
|
||||
if delta > 0 {
|
||||
m.noteCursor = 0
|
||||
} else {
|
||||
m.noteCursor = len(notes) - 1
|
||||
}
|
||||
default:
|
||||
m.noteCursor = (m.noteCursor + delta + len(notes)) % len(notes)
|
||||
}
|
||||
m.refreshDetailContent()
|
||||
return m
|
||||
}
|
||||
|
||||
// ── Alert detail (read-only) ──────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleAlertDetailKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "backspace":
|
||||
m.mode = modeDashboard
|
||||
m.statusMsg = ""
|
||||
return m, nil
|
||||
|
||||
case "i":
|
||||
// Jump to the incident this alert belongs to — the place where anything
|
||||
// can actually be done about it.
|
||||
if m.selectedAlert.IncidentID == nil {
|
||||
m.statusMsg = "this alert has no incident"
|
||||
return m, clearStatusCmd()
|
||||
}
|
||||
return m.openIncident(api.Incident{ID: *m.selectedAlert.IncidentID})
|
||||
|
||||
case "S":
|
||||
return m.openStats()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Note compose ──────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleNoteKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = modeDetail
|
||||
m.commentInput.Blur()
|
||||
m.mode = modeIncidentDetail
|
||||
m.noteInput.Blur()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
content := strings.TrimSpace(m.commentInput.Value())
|
||||
content := strings.TrimSpace(m.noteInput.Value())
|
||||
if content == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.mode = modeDetail
|
||||
m.commentInput.Blur()
|
||||
m.mode = modeIncidentDetail
|
||||
m.noteInput.Blur()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, addCommentCmd(m.client, m.selectedAlert.ID, content)
|
||||
return m, addNoteCmd(m.client, m.selectedIncident.ID, content)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Confirm delete ────────────────────────────────────────────────────────
|
||||
// ── Snooze ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleSnoozeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = modeIncidentDetail
|
||||
m.snoozeInput.Blur()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
duration := strings.TrimSpace(m.snoozeInput.Value())
|
||||
if duration == "" {
|
||||
return m, nil
|
||||
}
|
||||
m.mode = modeIncidentDetail
|
||||
m.snoozeInput.Blur()
|
||||
m.detailViewport.Height = m.detailViewportHeight()
|
||||
return m, snoozeIncidentCmd(m.client, m.selectedIncident.ID, duration)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// ── Confirm ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch strings.ToLower(msg.String()) {
|
||||
case "y":
|
||||
if strings.ToLower(msg.String()) != "y" {
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
alertID := m.selectedAlert.ID
|
||||
commentID := m.pendingDeleteID
|
||||
m.mode = modeDetail
|
||||
m.commentCursor = -1
|
||||
m.pendingDeleteID = 0
|
||||
return m, deleteCommentCmd(m.client, alertID, commentID)
|
||||
case confirmDeleteScheduleEntry:
|
||||
entry := m.pendingDeleteEntry
|
||||
m.mode = modeDashboard
|
||||
m.pendingDeleteEntry = nil
|
||||
m.scheduleLoading = true
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
return m, deleteScheduleEntryCmd(m.client, entry.ID, from, to)
|
||||
case confirmDeleteUser:
|
||||
userID := m.selectedUser.ID
|
||||
m.mode = modeDashboard
|
||||
m.usersLoading = true
|
||||
return m, deleteUserCmd(m.client, userID)
|
||||
}
|
||||
default:
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
m.mode = modeDetail
|
||||
case confirmDeleteScheduleEntry:
|
||||
m.mode = modeDashboard
|
||||
case confirmDeleteUser:
|
||||
case confirmDeleteNote, confirmResolveIncident:
|
||||
m.mode = modeIncidentDetail
|
||||
default:
|
||||
m.mode = modeDashboard
|
||||
}
|
||||
m.pendingDeleteID = 0
|
||||
m.pendingDeleteEntry = nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteNote:
|
||||
incidentID := m.selectedIncident.ID
|
||||
eventID := m.pendingDeleteID
|
||||
m.mode = modeIncidentDetail
|
||||
m.noteCursor = -1
|
||||
m.pendingDeleteID = 0
|
||||
return m, deleteNoteCmd(m.client, incidentID, eventID)
|
||||
|
||||
case confirmResolveIncident:
|
||||
m.mode = modeIncidentDetail
|
||||
return m, resolveIncidentCmd(m.client, m.selectedIncident.ID)
|
||||
|
||||
case confirmDeleteScheduleEntry:
|
||||
entry := m.pendingDeleteEntry
|
||||
m.mode = modeDashboard
|
||||
m.pendingDeleteEntry = nil
|
||||
m.scheduleLoading = true
|
||||
return m, deleteScheduleEntryCmd(m.client, entry.ID,
|
||||
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
|
||||
case confirmDeleteUser:
|
||||
userID := m.selectedUser.ID
|
||||
m.mode = modeDashboard
|
||||
m.usersLoading = true
|
||||
return m, deleteUserCmd(m.client, userID)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -664,26 +839,45 @@ func (m Model) handleConfirmKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
|
||||
func (m Model) handleStatsKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
if msg.String() == "esc" {
|
||||
m.mode = modeDetail
|
||||
m.mode = m.statsReturnMode
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// openStats enters the statistics view, remembering where to go back to.
|
||||
func (m Model) openStats() (Model, tea.Cmd) {
|
||||
m.statsReturnMode = m.mode
|
||||
m.mode = modeStats
|
||||
m.statsLoading = true
|
||||
m.statsViewport = viewport.New(m.width, m.height-5)
|
||||
return m, fetchDetailStatsCmd(m.client)
|
||||
}
|
||||
|
||||
// ── User picker ───────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = modeDashboard
|
||||
if m.pickerTarget == pickerIncidentAssignee {
|
||||
m.mode = modeIncidentDetail
|
||||
} else {
|
||||
m.mode = modeDashboard
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
cursor := m.userPickerTable.Cursor()
|
||||
if cursor >= len(m.users) {
|
||||
if cursor < 0 || cursor >= len(m.users) {
|
||||
return m, nil
|
||||
}
|
||||
user := m.users[cursor]
|
||||
|
||||
if m.pickerTarget == pickerIncidentAssignee {
|
||||
m.mode = modeIncidentDetail
|
||||
return m, assignIncidentCmd(m.client, m.selectedIncident.ID, user.ID)
|
||||
}
|
||||
|
||||
scheduleCursor := m.scheduleTable.Cursor()
|
||||
if scheduleCursor >= len(m.scheduleDays) {
|
||||
m.mode = modeDashboard
|
||||
@@ -703,11 +897,10 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
} else {
|
||||
dates = []string{d.Format("2006-01-02")}
|
||||
}
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
m.mode = modeDashboard
|
||||
m.scheduleLoading = true
|
||||
return m, assignScheduleCmd(m.client, user.ID, dates, from, to)
|
||||
return m, assignScheduleCmd(m.client, user.ID, dates,
|
||||
m.scheduleWindow, m.scheduleWindow.AddDate(0, 0, 6))
|
||||
}
|
||||
|
||||
return m, nil
|
||||
|
||||
+407
-238
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/yeniklas/terdut-tui/internal/api"
|
||||
)
|
||||
|
||||
var sectionNames = []string{"Alerts", "Archived", "Schedule", "Users"}
|
||||
var sectionNames = []string{"Incidents", "Alerts", "Archived", "Schedule", "Users"}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width == 0 {
|
||||
@@ -27,11 +27,7 @@ func (m Model) View() string {
|
||||
func (m Model) renderHeader() string {
|
||||
title := styleHeader.Render("terdut-tui")
|
||||
right := styleMuted.Render(m.serverURL)
|
||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return title + strings.Repeat(" ", gap) + right
|
||||
return spread(title, right, m.width)
|
||||
}
|
||||
|
||||
func (m Model) renderTabs() string {
|
||||
@@ -57,13 +53,15 @@ func (m Model) renderBody() string {
|
||||
}
|
||||
|
||||
switch m.mode {
|
||||
case modeDetail:
|
||||
case modeIncidentDetail, modeAlertDetail:
|
||||
return m.renderDetail()
|
||||
case modeComment:
|
||||
return m.renderCommentCompose()
|
||||
case modeConfirmDelete:
|
||||
case modeNote:
|
||||
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View())
|
||||
case modeSnooze:
|
||||
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View())
|
||||
case modeConfirm:
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
case confirmDeleteNote, confirmResolveIncident:
|
||||
return m.renderDetail()
|
||||
case confirmDeleteUser:
|
||||
return m.renderUsers()
|
||||
@@ -72,7 +70,7 @@ func (m Model) renderBody() string {
|
||||
}
|
||||
case modeStats:
|
||||
return m.renderStats()
|
||||
case modeScheduleUserPicker:
|
||||
case modeUserPicker:
|
||||
return m.renderUserPicker()
|
||||
case modeUserCreate:
|
||||
return m.renderUserCreate()
|
||||
@@ -90,133 +88,103 @@ func (m Model) renderBody() string {
|
||||
}
|
||||
|
||||
func (m Model) renderFooter() string {
|
||||
switch m.mode {
|
||||
case modeDetail:
|
||||
var actions string
|
||||
if m.activeSection == sectionArchived {
|
||||
actions = styleFooter.Render(" x·unarchive c·comment [/]·select d·del S·stats esc·back")
|
||||
} else {
|
||||
actions = styleFooter.Render(" a·ack A·unack x·archive c·comment [/]·select d·del s·assign S·stats esc·back")
|
||||
}
|
||||
withStatus := func(actions string) string {
|
||||
rendered := styleFooter.Render(actions)
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered
|
||||
}
|
||||
return "\n" + actions
|
||||
return "\n" + rendered
|
||||
}
|
||||
|
||||
case modeComment:
|
||||
switch m.mode {
|
||||
case modeIncidentDetail:
|
||||
if !m.selectedIncident.IsOpen() {
|
||||
return withStatus(" x·archive c·note [/]·select d·del S·stats esc·back")
|
||||
}
|
||||
return withStatus(" a·ack A·unack R·resolve s·assign z·snooze Z·unsnooze c·note [/]·select d·del S·stats esc·back")
|
||||
|
||||
case modeAlertDetail:
|
||||
return withStatus(" i·open incident S·stats esc·back")
|
||||
|
||||
case modeNote:
|
||||
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
|
||||
|
||||
case modeConfirmDelete:
|
||||
var deleteDesc string
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteComment:
|
||||
if m.commentCursor >= 0 && m.commentCursor < len(m.comments) {
|
||||
deleteDesc = fmt.Sprintf("comment by %s", m.comments[m.commentCursor].Username)
|
||||
} else {
|
||||
deleteDesc = "comment"
|
||||
}
|
||||
case confirmDeleteScheduleEntry:
|
||||
if m.pendingDeleteEntry != nil {
|
||||
deleteDesc = fmt.Sprintf("on-call for %s (%s)", m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
|
||||
} else {
|
||||
deleteDesc = "schedule entry"
|
||||
}
|
||||
case confirmDeleteUser:
|
||||
deleteDesc = fmt.Sprintf("user %s (cascades all API keys)", m.selectedUser.Username)
|
||||
}
|
||||
return "\n" + styleError.Render(fmt.Sprintf(" Delete %s? [y/N]", deleteDesc))
|
||||
case modeSnooze:
|
||||
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
|
||||
|
||||
case modeConfirm:
|
||||
return "\n" + styleError.Render(" "+m.confirmPrompt())
|
||||
|
||||
case modeStats:
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" esc·back")
|
||||
}
|
||||
return "\n" + styleFooter.Render(" esc·back")
|
||||
return withStatus(" esc·back")
|
||||
|
||||
case modeScheduleUserPicker:
|
||||
mode := "day"
|
||||
if m.pickerAssignWeek {
|
||||
mode = "week"
|
||||
case modeUserPicker:
|
||||
if m.pickerTarget == pickerIncidentAssignee {
|
||||
return withStatus(" j/k·navigate enter·assign incident esc·cancel")
|
||||
}
|
||||
return "\n" + styleFooter.Render(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", mode))
|
||||
scope := "day"
|
||||
if m.pickerAssignWeek {
|
||||
scope = "week"
|
||||
}
|
||||
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
|
||||
|
||||
case modeUserCreate:
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
|
||||
}
|
||||
return "\n" + styleFooter.Render(" tab·next field enter·create esc·cancel")
|
||||
return withStatus(" tab·next field enter·create esc·cancel")
|
||||
|
||||
case modeAPIKeyMenu:
|
||||
return "\n" + styleFooter.Render(" n·new key r·revoke by ID esc·back")
|
||||
return withStatus(" n·new key r·revoke by ID esc·back")
|
||||
|
||||
case modeAPIKeyCreate:
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·create esc·back")
|
||||
}
|
||||
return "\n" + styleFooter.Render(" enter·create esc·back")
|
||||
return withStatus(" enter·create esc·back")
|
||||
|
||||
case modeAPIKeyReveal:
|
||||
footer := " c·copy to clipboard esc·done"
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(footer)
|
||||
}
|
||||
return "\n" + styleFooter.Render(footer)
|
||||
return withStatus(" c·copy to clipboard esc·done")
|
||||
|
||||
case modeAPIKeyRevokeByID:
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + styleFooter.Render(" enter·revoke esc·back")
|
||||
}
|
||||
return "\n" + styleFooter.Render(" enter·revoke esc·back")
|
||||
return withStatus(" enter·revoke esc·back")
|
||||
|
||||
default:
|
||||
if m.activeSection == sectionArchived {
|
||||
actions := styleFooter.Render(" x·unarchive enter·detail r·refresh tab·section q·quit")
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
|
||||
}
|
||||
return "\n" + actions
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return withStatus(" enter·detail x·archive f·filter S·stats r·refresh tab·section q·quit")
|
||||
case sectionAlerts:
|
||||
return withStatus(" enter·detail f·filter S·stats r·refresh tab·section q·quit")
|
||||
case sectionArchived:
|
||||
return withStatus(" enter·detail x·unarchive r·refresh tab·section q·quit")
|
||||
case sectionSchedule:
|
||||
return withStatus(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
||||
case sectionUsers:
|
||||
return withStatus(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
|
||||
}
|
||||
if m.activeSection == sectionSchedule {
|
||||
actions := styleFooter.Render(" +·assign day W·assign week d·del ←/→·shift week tab·section r·refresh q·quit")
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
|
||||
}
|
||||
return "\n" + actions
|
||||
}
|
||||
if m.activeSection == sectionUsers {
|
||||
actions := styleFooter.Render(" n·new user d·delete k·API keys r·refresh tab·section q·quit")
|
||||
if m.statusMsg != "" {
|
||||
return styleStatus.Render(" "+m.statusMsg) + "\n" + actions
|
||||
}
|
||||
return "\n" + actions
|
||||
}
|
||||
var footerLeft string
|
||||
if m.activeSection == sectionAlerts {
|
||||
footerLeft = styleFooter.Render(" x·archive")
|
||||
}
|
||||
helpView := styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||
if m.statusMsg != "" {
|
||||
status := styleStatus.Render(m.statusMsg)
|
||||
gap := m.width - lipgloss.Width(status) - lipgloss.Width(helpView)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return "\n" + status + strings.Repeat(" ", gap) + helpView
|
||||
}
|
||||
if footerLeft != "" {
|
||||
gap := m.width - lipgloss.Width(footerLeft) - lipgloss.Width(helpView)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return "\n" + footerLeft + strings.Repeat(" ", gap) + helpView
|
||||
}
|
||||
return "\n" + helpView
|
||||
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) confirmPrompt() string {
|
||||
switch m.confirmTarget {
|
||||
case confirmDeleteNote:
|
||||
return "Delete this note? [y/N]"
|
||||
case confirmResolveIncident:
|
||||
// Manual resolution is terminal on the server, so say so before asking.
|
||||
return "Resolve incident? This is final — a new occurrence opens a new incident. [y/N]"
|
||||
case confirmDeleteScheduleEntry:
|
||||
if m.pendingDeleteEntry != nil {
|
||||
return fmt.Sprintf("Delete on-call for %s (%s)? [y/N]",
|
||||
m.pendingDeleteEntry.Date, m.pendingDeleteEntry.Username)
|
||||
}
|
||||
return "Delete schedule entry? [y/N]"
|
||||
case confirmDeleteUser:
|
||||
return fmt.Sprintf("Delete user %s (cascades all API keys)? [y/N]", m.selectedUser.Username)
|
||||
}
|
||||
return "Are you sure? [y/N]"
|
||||
}
|
||||
|
||||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderDashboard() string {
|
||||
switch m.activeSection {
|
||||
case sectionIncidents:
|
||||
return m.renderIncidents()
|
||||
case sectionAlerts:
|
||||
return m.renderAlerts()
|
||||
case sectionArchived:
|
||||
@@ -229,6 +197,90 @@ func (m Model) renderDashboard() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m Model) renderIncidents() string {
|
||||
bar := m.renderIncidentStatsBar()
|
||||
var content string
|
||||
switch {
|
||||
case m.loading && len(m.incidents) == 0:
|
||||
content = styleMuted.Render(" Loading incidents…")
|
||||
case len(m.incidents) == 0:
|
||||
content = styleMuted.Render(
|
||||
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
|
||||
default:
|
||||
content = m.incidentTable.View()
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
|
||||
}
|
||||
|
||||
func (m Model) renderAlerts() string {
|
||||
bar := m.renderAlertStatsBar()
|
||||
var content string
|
||||
switch {
|
||||
case m.loading && len(m.alerts) == 0:
|
||||
content = styleMuted.Render(" Loading alerts…")
|
||||
case len(m.alerts) == 0:
|
||||
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
|
||||
default:
|
||||
content = m.alertTable.View()
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, bar, content)
|
||||
}
|
||||
|
||||
func (m Model) renderArchived() string {
|
||||
if m.archivedLoading {
|
||||
return "\n" + styleMuted.Render(" Loading archived incidents…")
|
||||
}
|
||||
if len(m.archivedIncidents) == 0 {
|
||||
return "\n" + styleMuted.Render(" No archived incidents.")
|
||||
}
|
||||
return "\n" + m.archivedTable.View()
|
||||
}
|
||||
|
||||
func (m Model) renderIncidentStatsBar() string {
|
||||
var triggered, acked, resolved int
|
||||
mtta, mttr := "—", "—"
|
||||
if m.incidentStats != nil {
|
||||
triggered = m.incidentStats.Triggered
|
||||
acked = m.incidentStats.Acknowledged
|
||||
resolved = m.incidentStats.Resolved
|
||||
mtta = humanSeconds(m.incidentStats.MTTASeconds)
|
||||
mttr = humanSeconds(m.incidentStats.MTTRSeconds)
|
||||
}
|
||||
left := fmt.Sprintf(" %s %s %s %s",
|
||||
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
|
||||
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
|
||||
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||||
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
|
||||
)
|
||||
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
|
||||
return spread(left, right, m.width)
|
||||
}
|
||||
|
||||
func (m Model) renderAlertStatsBar() string {
|
||||
total, firing, resolved := 0, 0, 0
|
||||
if m.alertStats != nil {
|
||||
total = m.alertStats.Total
|
||||
firing = m.alertStats.Firing
|
||||
resolved = m.alertStats.Resolved
|
||||
}
|
||||
left := fmt.Sprintf(" Total: %d %s %s",
|
||||
total,
|
||||
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
|
||||
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||||
)
|
||||
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
|
||||
return spread(left, right, m.width)
|
||||
}
|
||||
|
||||
// spread pushes left and right to the edges of a width.
|
||||
func spread(left, right string, width int) string {
|
||||
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return left + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
// ── Schedule ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderSchedule() string {
|
||||
@@ -236,7 +288,6 @@ func (m Model) renderSchedule() string {
|
||||
return "\n" + styleMuted.Render(" Loading schedule…")
|
||||
}
|
||||
|
||||
// On-call header
|
||||
var onCallLine string
|
||||
if m.currentOnCall != nil {
|
||||
onCallLine = fmt.Sprintf(" On-call today: %s",
|
||||
@@ -245,17 +296,12 @@ func (m Model) renderSchedule() string {
|
||||
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
|
||||
}
|
||||
|
||||
// Window label
|
||||
from := m.scheduleWindow
|
||||
to := m.scheduleWindow.AddDate(0, 0, 6)
|
||||
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
|
||||
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
|
||||
|
||||
gap := m.width - lipgloss.Width(onCallLine) - lipgloss.Width(windowLabel)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
header := "\n" + onCallLine + strings.Repeat(" ", gap) + windowLabel + "\n"
|
||||
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
|
||||
return header + m.scheduleTable.View()
|
||||
}
|
||||
|
||||
@@ -264,6 +310,12 @@ func (m Model) renderUserPicker() string {
|
||||
return "\n" + styleMuted.Render(" Loading users…")
|
||||
}
|
||||
|
||||
if m.pickerTarget == pickerIncidentAssignee {
|
||||
header := fmt.Sprintf("\n Assign %s to:\n\n",
|
||||
styleBold.Render(m.selectedIncident.Title))
|
||||
return header + m.userPickerTable.View()
|
||||
}
|
||||
|
||||
var scope string
|
||||
cursor := m.scheduleTable.Cursor()
|
||||
if cursor >= 0 && cursor < len(m.scheduleDays) {
|
||||
@@ -278,85 +330,30 @@ func (m Model) renderUserPicker() string {
|
||||
_, week := d.ISOWeek()
|
||||
scope = fmt.Sprintf("week W%02d (%s–%s)",
|
||||
week, monday.Format("Jan 02"), sunday.Format("Jan 02"))
|
||||
} else if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
|
||||
scope = "Today (" + d.Format("Mon") + ")"
|
||||
} else {
|
||||
if d.Format("2006-01-02") == time.Now().UTC().Format("2006-01-02") {
|
||||
scope = "Today (" + d.Format("Mon") + ")"
|
||||
} else {
|
||||
scope = d.Format("Jan 02 (Mon)")
|
||||
}
|
||||
scope = d.Format("Jan 02 (Mon)")
|
||||
}
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n",
|
||||
styleBold.Render(scope))
|
||||
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope))
|
||||
return header + m.userPickerTable.View()
|
||||
}
|
||||
|
||||
func (m Model) renderAlerts() string {
|
||||
statsBar := m.renderStatsBar()
|
||||
var content string
|
||||
if m.loading && len(m.alerts) == 0 {
|
||||
content = styleMuted.Render(" Loading alerts…")
|
||||
} else if len(m.alerts) == 0 {
|
||||
label := m.filterStatus
|
||||
if label == "" {
|
||||
label = "all"
|
||||
}
|
||||
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", label))
|
||||
} else {
|
||||
content = m.alertTable.View()
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, statsBar, content)
|
||||
}
|
||||
|
||||
func (m Model) renderArchived() string {
|
||||
if m.archivedLoading {
|
||||
return styleMuted.Render(" Loading archived alerts…")
|
||||
}
|
||||
if len(m.archivedAlerts) == 0 {
|
||||
return styleMuted.Render(" No archived alerts.")
|
||||
}
|
||||
return m.archivedTable.View()
|
||||
}
|
||||
|
||||
func (m Model) renderStatsBar() string {
|
||||
total, firing, resolved := 0, 0, 0
|
||||
if m.stats != nil {
|
||||
total = m.stats.Total
|
||||
firing = m.stats.Firing
|
||||
resolved = m.stats.Resolved
|
||||
}
|
||||
filterLabel := m.filterStatus
|
||||
if filterLabel == "" {
|
||||
filterLabel = "all"
|
||||
}
|
||||
left := fmt.Sprintf(" Total: %d %s %s",
|
||||
total,
|
||||
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
|
||||
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
|
||||
)
|
||||
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel))
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
return left + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
// ── Detail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (m Model) renderDetail() string {
|
||||
if m.detailLoading {
|
||||
return "\n" + styleMuted.Render(" Loading alert details…")
|
||||
return "\n" + styleMuted.Render(" Loading…")
|
||||
}
|
||||
return m.detailViewport.View()
|
||||
}
|
||||
|
||||
func (m Model) renderCommentCompose() string {
|
||||
vp := m.detailViewport.View()
|
||||
// renderPrompt puts an input line under the detail pane.
|
||||
func (m Model) renderPrompt(prompt string) string {
|
||||
sep := styleMuted.Render(strings.Repeat("─", m.width))
|
||||
prompt := styleHeader.Render("Comment: ") + m.commentInput.View()
|
||||
return vp + "\n" + sep + "\n" + prompt
|
||||
return m.detailViewport.View() + "\n" + sep + "\n" + prompt
|
||||
}
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
@@ -370,12 +367,191 @@ func (m Model) renderStats() string {
|
||||
|
||||
// ── Content builders ───────────────────────────────────────────────────────
|
||||
|
||||
func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width int) string {
|
||||
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
|
||||
now := time.Now()
|
||||
var b strings.Builder
|
||||
contentW := width - 4
|
||||
|
||||
// Title + status header
|
||||
title := styleAlertName.Render(inc.Title)
|
||||
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc))
|
||||
if inc.Severity != "" {
|
||||
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity))
|
||||
}
|
||||
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
b.WriteString("\n " + title + strings.Repeat(" ", gap) + status + "\n\n")
|
||||
|
||||
// Timing and ownership
|
||||
b.WriteString(fmt.Sprintf(" Triggered: %s (%s)\n",
|
||||
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
|
||||
|
||||
if inc.AssignedTo != "" {
|
||||
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo)))
|
||||
} else {
|
||||
b.WriteString(styleMuted.Render(" Assigned: nobody\n"))
|
||||
}
|
||||
|
||||
if inc.AcknowledgedByID != nil {
|
||||
ackAt := ""
|
||||
if inc.AcknowledgedAt != nil {
|
||||
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
b.WriteString(styleResolved.Render(
|
||||
fmt.Sprintf(" Acked: %s%s\n", inc.AcknowledgedBy, ackAt)))
|
||||
} else {
|
||||
b.WriteString(styleMuted.Render(" Acked: not acknowledged\n"))
|
||||
}
|
||||
|
||||
if inc.IsSnoozed() {
|
||||
b.WriteString(styleSnoozed.Render(fmt.Sprintf(" Snoozed: until %s (%s)\n",
|
||||
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
|
||||
}
|
||||
|
||||
if inc.ResolvedAt != nil {
|
||||
source := ""
|
||||
if inc.ResolutionSource != nil {
|
||||
source = " · " + *inc.ResolutionSource
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Resolved: %s (%s)%s\n",
|
||||
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
|
||||
}
|
||||
if inc.ArchivedAt != nil {
|
||||
b.WriteString(styleMuted.Render(" Archived: " +
|
||||
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC") + "\n"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Group labels — the correlation Alertmanager applied.
|
||||
if len(inc.GroupLabels) > 0 {
|
||||
b.WriteString(divider("Grouped By", width))
|
||||
for _, k := range sortedKeys(inc.GroupLabels) {
|
||||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Member alerts
|
||||
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
|
||||
if len(inc.Alerts) == 0 {
|
||||
b.WriteString(styleMuted.Render(" No alerts.\n"))
|
||||
} else {
|
||||
for _, a := range inc.Alerts {
|
||||
marker := styleFiring.Render("●")
|
||||
if a.Status != "firing" {
|
||||
marker = styleResolved.Render("✓")
|
||||
}
|
||||
instance := a.Labels["instance"]
|
||||
if instance == "" {
|
||||
instance = a.Fingerprint
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
|
||||
marker, truncate(a.Name, 28), truncate(instance, 26),
|
||||
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Timeline — the only history the server keeps.
|
||||
notes := noteEvents(timeline)
|
||||
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
|
||||
if len(timeline) == 0 {
|
||||
b.WriteString(styleMuted.Render(" Nothing recorded yet.\n"))
|
||||
} else {
|
||||
noteIndex := 0
|
||||
for _, e := range timeline {
|
||||
when := styleMuted.Render(humanAgo(now, e.CreatedAt))
|
||||
if e.Type != api.EventNote {
|
||||
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
|
||||
continue
|
||||
}
|
||||
marker := " "
|
||||
author := styleBold.Render(e.Username)
|
||||
if noteIndex == cursor {
|
||||
marker = styleSelected.Render("> ")
|
||||
author = styleSelected.Render(e.Username)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
|
||||
b.WriteString(" " + e.Detail + "\n")
|
||||
noteIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// incidentStatusLabel is the headline badge for an incident.
|
||||
func incidentStatusLabel(inc api.Incident) string {
|
||||
switch inc.Status {
|
||||
case api.StatusTriggered:
|
||||
if inc.IsSnoozed() {
|
||||
return "● TRIGGERED (snoozed)"
|
||||
}
|
||||
return "● TRIGGERED"
|
||||
case api.StatusAcknowledged:
|
||||
if inc.IsSnoozed() {
|
||||
return "◐ ACKNOWLEDGED (snoozed)"
|
||||
}
|
||||
return "◐ ACKNOWLEDGED"
|
||||
case api.StatusResolved:
|
||||
return "✓ RESOLVED"
|
||||
default:
|
||||
return strings.ToUpper(inc.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// eventLabel renders one timeline entry as a sentence. Unrecognised types fall
|
||||
// back to their raw name rather than vanishing — the server may add more.
|
||||
func eventLabel(e api.IncidentEvent) string {
|
||||
who := e.Username
|
||||
switch e.Type {
|
||||
case api.EventTriggered:
|
||||
return " Incident opened"
|
||||
case api.EventAlertAdded:
|
||||
if e.AlertID != nil {
|
||||
return fmt.Sprintf(" Alert #%d joined", *e.AlertID)
|
||||
}
|
||||
return " Alert joined"
|
||||
case api.EventAlertResolved:
|
||||
if e.AlertID != nil {
|
||||
return fmt.Sprintf(" Alert #%d resolved", *e.AlertID)
|
||||
}
|
||||
return " Alert resolved"
|
||||
case api.EventAcknowledged:
|
||||
return " Acknowledged by " + who
|
||||
case api.EventUnacknowledged:
|
||||
return " Acknowledgement cleared by " + who
|
||||
case api.EventAssigned:
|
||||
// On an assigned event the user is the assignee, not the actor.
|
||||
return " Assigned to " + who
|
||||
case api.EventSnoozed:
|
||||
if e.Detail != "" {
|
||||
return " Snoozed until " + e.Detail
|
||||
}
|
||||
return " Snoozed"
|
||||
case api.EventUnsnoozed:
|
||||
return " Snooze cleared by " + who
|
||||
case api.EventResolved:
|
||||
if who != "" {
|
||||
return " Resolved by " + who
|
||||
}
|
||||
return " Resolved (all alerts stopped firing)"
|
||||
default:
|
||||
label := " " + e.Type
|
||||
if e.Detail != "" {
|
||||
label += " · " + e.Detail
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
func buildAlertDetailContent(alert api.Alert, width int) string {
|
||||
now := time.Now()
|
||||
var b strings.Builder
|
||||
contentW := width - 4
|
||||
|
||||
// Name + status header
|
||||
name := styleAlertName.Render(alert.Name)
|
||||
var statusStr string
|
||||
if alert.Status == "firing" {
|
||||
@@ -387,13 +563,12 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
|
||||
}
|
||||
statusStr = styleResolved.Render(label)
|
||||
}
|
||||
nameGap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
|
||||
if nameGap < 1 {
|
||||
nameGap = 1
|
||||
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
b.WriteString("\n " + name + strings.Repeat(" ", nameGap) + statusStr + "\n\n")
|
||||
b.WriteString("\n " + name + strings.Repeat(" ", gap) + statusStr + "\n\n")
|
||||
|
||||
// Timeline
|
||||
b.WriteString(fmt.Sprintf(" Started: %s (%s)\n",
|
||||
alert.StartsAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, alert.StartsAt)))
|
||||
b.WriteString(fmt.Sprintf(" Last Seen: %s (%s)\n",
|
||||
@@ -402,74 +577,42 @@ func buildDetailContent(alert api.Alert, comments []api.Comment, cursor, width i
|
||||
b.WriteString(fmt.Sprintf(" Ended: %s\n", alert.EndsAt.UTC().Format("2006-01-02 15:04 UTC")))
|
||||
}
|
||||
if alert.GeneratorURL != "" {
|
||||
url := alert.GeneratorURL
|
||||
if len(url) > contentW-12 {
|
||||
url = url[:contentW-15] + "…"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Source: %s\n", url))
|
||||
b.WriteString(fmt.Sprintf(" Source: %s\n", truncate(alert.GeneratorURL, contentW-12)))
|
||||
}
|
||||
if alert.IncidentID != nil {
|
||||
b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
|
||||
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
|
||||
styleMuted.Render("press i to open it")))
|
||||
} else {
|
||||
b.WriteString(styleMuted.Render(" Incident: none\n"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Labels
|
||||
if len(alert.Labels) > 0 {
|
||||
b.WriteString(divider("Labels", width))
|
||||
for _, k := range sortedKeys(alert.Labels) {
|
||||
v := alert.Labels[k]
|
||||
if len(v) > contentW-24 {
|
||||
v = v[:contentW-27] + "…"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
|
||||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Annotations
|
||||
if len(alert.Annotations) > 0 {
|
||||
b.WriteString(divider("Annotations", width))
|
||||
for _, k := range sortedKeys(alert.Annotations) {
|
||||
v := alert.Annotations[k]
|
||||
if len(v) > contentW-24 {
|
||||
v = v[:contentW-27] + "…"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, v))
|
||||
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Acknowledgement
|
||||
b.WriteString(divider("Acknowledgement", width))
|
||||
if alert.AcknowledgedByID != nil {
|
||||
ackAt := ""
|
||||
if alert.AcknowledgedAt != nil {
|
||||
ackAt = " at " + alert.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
b.WriteString(styleResolved.Render(fmt.Sprintf(" ✓ Acknowledged by %s%s\n", alert.AcknowledgedBy, ackAt)))
|
||||
} else {
|
||||
b.WriteString(styleMuted.Render(" Not acknowledged\n"))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Comments
|
||||
b.WriteString(divider(fmt.Sprintf("Comments (%d)", len(comments)), width))
|
||||
if len(comments) == 0 {
|
||||
b.WriteString(styleMuted.Render(" No comments yet.\n"))
|
||||
} else {
|
||||
for i, c := range comments {
|
||||
prefix := " "
|
||||
authorLine := fmt.Sprintf("%s%s • %s", prefix, styleBold.Render(c.Username), humanAgo(now, c.CreatedAt))
|
||||
if i == cursor {
|
||||
authorLine = styleSelected.Render("> ") + styleBold.Render(c.Username) +
|
||||
styleMuted.Render(fmt.Sprintf(" • %s", humanAgo(now, c.CreatedAt)))
|
||||
}
|
||||
b.WriteString(authorLine + "\n")
|
||||
b.WriteString(" " + c.Content + "\n\n")
|
||||
}
|
||||
}
|
||||
// Alerts carry no workflow state: it all lives on the incident.
|
||||
b.WriteString(divider("", width))
|
||||
b.WriteString(styleMuted.Render(
|
||||
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.\n"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
|
||||
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
|
||||
barWidth := width/2 - 10
|
||||
if barWidth < 8 {
|
||||
barWidth = 8
|
||||
@@ -481,7 +624,29 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
|
||||
var b strings.Builder
|
||||
b.WriteString("\n")
|
||||
|
||||
// Top alerts
|
||||
// Response times first: they are what a rota is actually judged on.
|
||||
b.WriteString(divider("Incident Response", width))
|
||||
if incidents == nil {
|
||||
b.WriteString(styleMuted.Render(" No data.\n"))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
|
||||
styleBold.Render(fmt.Sprintf("%d", incidents.Total))))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
|
||||
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
|
||||
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
|
||||
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
|
||||
styleBold.Render(humanSeconds(incidents.MTTASeconds))))
|
||||
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
|
||||
styleBold.Render(humanSeconds(incidents.MTTRSeconds))))
|
||||
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
|
||||
b.WriteString(styleMuted.Render(" (— means nothing has been acknowledged or resolved yet)\n"))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString(divider("Top Alerts", width))
|
||||
if len(top) == 0 {
|
||||
b.WriteString(styleMuted.Render(" No data.\n"))
|
||||
@@ -494,7 +659,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// By hour
|
||||
b.WriteString(divider("Alerts by Hour (UTC)", width))
|
||||
if len(byHour) > 0 {
|
||||
maxCount := 0
|
||||
@@ -512,7 +676,6 @@ func buildStatsContent(top []api.TopAlert, byHour []api.HourStat, byDay []api.Da
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// By day
|
||||
b.WriteString(divider("Alerts by Day", width))
|
||||
if len(byDay) > 0 {
|
||||
maxCount := 0
|
||||
@@ -602,7 +765,10 @@ func (m Model) renderAPIKeyRevokeByID() string {
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
func divider(title string, width int) string {
|
||||
prefix := "── " + title + " "
|
||||
prefix := "── "
|
||||
if title != "" {
|
||||
prefix += title + " "
|
||||
}
|
||||
remaining := width - len(prefix) - 2
|
||||
if remaining > 0 {
|
||||
prefix += strings.Repeat("─", remaining)
|
||||
@@ -631,6 +797,9 @@ func renderBarWidth(count, maxCount, maxWidth int) int {
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if max < 1 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user