d6c0f7508c
Assigning an on-call week panicked with "index out of range [-1]" on an ordinary schedule. The index came from scheduleTable.Cursor(). The cursor is not ours. bubbles' SetRows clamps it down when rows shrink (`if m.cursor > len(rows)-1`) but never back up, so setting zero rows drives it to -1 and filling the table afterwards leaves it there -- -1 is not greater than len-1, so nothing corrects it. Every table in this package is rebuilt from empty exactly once, when the first WindowSizeMsg arrives before any fetch has returned, so every cursor started at -1 and stayed there until the user pressed up or down. Pressing a direction key first is why this was survivable at all. setRows restores the invariant the rest of the package already assumes: a table with rows has a usable cursor. Every rebuild goes through it. Five call sites also bounds-checked only the top of the range, and are now consistent with their siblings, which already had `i < 0 ||`. They were the same latent panic: deleting a schedule entry, deleting a user, editing a topic, opening the API key menu, and the week assignment that actually fired. The regression test deliberately never calls SetCursor. That is what the existing schedule tests do, and SetCursor clamps, which is exactly how this got past them. It drives the real order instead: size, then data, then keys. Also carries a gofmt pass, which is why untouched files appear in the diff. The move to git.ryuvia.com rewrote import paths without re-sorting them, and the new path sorts before github.com/charmbracelet/..., where the old one sorted after. go vet does not look at import order, so CI had nothing to say.
1017 lines
27 KiB
Go
1017 lines
27 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-tui/internal/api"
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/table"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// ── Enums ──────────────────────────────────────────────────────────────────
|
|
|
|
type section int
|
|
|
|
const (
|
|
// Incidents lead: they are the work. Alerts is the raw feed underneath.
|
|
sectionIncidents section = iota
|
|
sectionAlerts
|
|
sectionStats
|
|
sectionArchived
|
|
sectionSchedule
|
|
sectionUsers
|
|
|
|
sectionCount = 6
|
|
)
|
|
|
|
type mode int
|
|
|
|
const (
|
|
modeDashboard mode = iota
|
|
modeIncidentDetail
|
|
modeAlertDetail
|
|
modeNote
|
|
modeSnooze
|
|
modeConfirm
|
|
modeUserPicker
|
|
modeUserCreate
|
|
modeUserNotifyEdit
|
|
modeAPIKeyMenu
|
|
modeAPIKeyCreate
|
|
modeAPIKeyReveal
|
|
modeAPIKeyRevokeByID
|
|
)
|
|
|
|
type confirmTarget int
|
|
|
|
const (
|
|
confirmDeleteNote confirmTarget = iota
|
|
confirmResolveIncident
|
|
confirmDeleteScheduleEntry
|
|
confirmDeleteUser
|
|
confirmReassignSchedule
|
|
)
|
|
|
|
// 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 statsFetchedMsg struct {
|
|
incidents api.IncidentStats
|
|
alerts api.AlertStats
|
|
}
|
|
type fetchDataErrMsg struct{ err error }
|
|
type tickMsg time.Time
|
|
type clearStatusMsg struct{}
|
|
|
|
// detail
|
|
type incidentDetailFetchedMsg struct {
|
|
incident api.Incident
|
|
timeline []api.IncidentEvent
|
|
}
|
|
type alertDetailFetchedMsg struct{ alert api.Alert }
|
|
type detailErrMsg struct{ err error }
|
|
type actionErrMsg struct{ err error }
|
|
type detailStatsFetchedMsg struct {
|
|
top []api.TopAlert
|
|
byHour []api.HourStat
|
|
byDay []api.DayStat
|
|
}
|
|
type detailStatsErrMsg struct{ err error }
|
|
|
|
// schedule
|
|
type scheduleFetchedMsg struct {
|
|
entries []api.ScheduleEntry
|
|
current *api.ScheduleEntry
|
|
}
|
|
type scheduleFetchErrMsg struct{ err error }
|
|
type scheduleActionErrMsg struct{ err error }
|
|
type usersFetchedMsg struct{ users []api.User }
|
|
|
|
// user management
|
|
type apiKeyCreatedMsg struct{ key api.APIKey }
|
|
type apiKeyRevokedMsg struct{}
|
|
type userActionErrMsg struct{ err error }
|
|
|
|
// ── Model ──────────────────────────────────────────────────────────────────
|
|
|
|
type scheduleDay struct {
|
|
date time.Time
|
|
entry *api.ScheduleEntry
|
|
}
|
|
|
|
// pendingAssign is an on-call assignment held back by the reassignment
|
|
// confirmation, because some of its dates belong to somebody else.
|
|
type pendingAssign struct {
|
|
userID int64
|
|
username string
|
|
dates []string
|
|
// taken are the dates currently held by other people, and holders the
|
|
// distinct names holding them — both only for wording the prompt.
|
|
taken []string
|
|
holders []string
|
|
}
|
|
|
|
type Model struct {
|
|
client *api.Client
|
|
serverURL string
|
|
refreshInterval time.Duration
|
|
|
|
activeSection section
|
|
mode mode
|
|
width int
|
|
height int
|
|
|
|
// Connection & dashboard
|
|
connected bool
|
|
loading bool
|
|
err error
|
|
statusMsg string
|
|
incidentStats *api.IncidentStats
|
|
alertStats *api.AlertStats
|
|
|
|
// Incidents
|
|
incidents []api.Incident
|
|
incidentFilter string
|
|
incidentTable table.Model
|
|
|
|
// Alerts (read-only feed)
|
|
alerts []api.Alert
|
|
alertFilter string
|
|
alertTable table.Model
|
|
|
|
// Archived incidents
|
|
archivedIncidents []api.Incident
|
|
archivedLoading bool
|
|
archivedTable table.Model
|
|
|
|
// 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
|
|
pendingAssign *pendingAssign
|
|
|
|
// Stats
|
|
topAlerts []api.TopAlert
|
|
hourStats []api.HourStat
|
|
dayStats []api.DayStat
|
|
// statsLoaded tracks the first fetch separately from emptiness: a server with
|
|
// no alerts yet legitimately returns three empty slices.
|
|
statsLoaded bool
|
|
statsLoading bool
|
|
statsViewport viewport.Model
|
|
|
|
// Schedule
|
|
scheduleWindow time.Time
|
|
scheduleEntries []api.ScheduleEntry
|
|
scheduleDays []scheduleDay
|
|
currentOnCall *api.ScheduleEntry
|
|
scheduleLoading bool
|
|
scheduleTable table.Model
|
|
|
|
// User picker (schedule assignment and incident assignee)
|
|
users []api.User
|
|
usersLoading bool
|
|
userPickerTable table.Model
|
|
pickerTarget pickerTarget
|
|
pickerAssignWeek bool
|
|
|
|
// User management section
|
|
userManageTable table.Model
|
|
selectedUser api.User
|
|
userFormInputs [2]textinput.Model
|
|
userFormFocus int
|
|
ntfyTopicInput textinput.Model
|
|
apiKeyNameInput textinput.Model
|
|
apiKeyRevokeInput textinput.Model
|
|
revealedAPIKey api.APIKey
|
|
|
|
help help.Model
|
|
keys keyMap
|
|
}
|
|
|
|
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)
|
|
|
|
archivedT := table.New(table.WithFocused(true))
|
|
archivedT.SetStyles(ts)
|
|
|
|
schedT := table.New(table.WithFocused(true))
|
|
schedT.SetStyles(ts)
|
|
|
|
pickerT := table.New(table.WithFocused(true))
|
|
pickerT.SetStyles(ts)
|
|
|
|
manageT := table.New(table.WithFocused(true))
|
|
manageT.SetStyles(ts)
|
|
|
|
// Sized by the first tea.WindowSizeMsg; built here so it carries the default
|
|
// scroll keymap, which the zero value lacks.
|
|
statsVP := viewport.New(0, 0)
|
|
|
|
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"
|
|
usernameIn.CharLimit = 64
|
|
|
|
emailIn := textinput.New()
|
|
emailIn.Placeholder = "email"
|
|
emailIn.CharLimit = 128
|
|
|
|
topicIn := textinput.New()
|
|
topicIn.Placeholder = "ntfy topic — empty clears it"
|
|
topicIn.CharLimit = 128
|
|
|
|
keyNameIn := textinput.New()
|
|
keyNameIn.Placeholder = "key name (e.g. laptop)"
|
|
keyNameIn.CharLimit = 64
|
|
|
|
revokeIn := textinput.New()
|
|
revokeIn.Placeholder = "integer key ID"
|
|
revokeIn.CharLimit = 20
|
|
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
weekday := int(today.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 7 // ISO: Sunday = 7
|
|
}
|
|
window := today.AddDate(0, 0, -(weekday - 1))
|
|
|
|
return Model{
|
|
client: client,
|
|
serverURL: serverURL,
|
|
refreshInterval: refreshInterval,
|
|
activeSection: sectionIncidents,
|
|
mode: modeDashboard,
|
|
loading: true,
|
|
incidentFilter: "",
|
|
alertFilter: "firing",
|
|
noteCursor: -1,
|
|
incidentTable: incidentT,
|
|
alertTable: alertT,
|
|
archivedTable: archivedT,
|
|
statsViewport: statsVP,
|
|
noteInput: noteIn,
|
|
snoozeInput: snoozeIn,
|
|
scheduleWindow: window,
|
|
scheduleTable: schedT,
|
|
userPickerTable: pickerT,
|
|
userManageTable: manageT,
|
|
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
|
|
ntfyTopicInput: topicIn,
|
|
apiKeyNameInput: keyNameIn,
|
|
apiKeyRevokeInput: revokeIn,
|
|
help: help.New(),
|
|
keys: keys,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return connectCmd(m.client)
|
|
}
|
|
|
|
// ── Table rebuilders ───────────────────────────────────────────────────────
|
|
|
|
func defaultTableStyles() table.Styles {
|
|
s := table.DefaultStyles()
|
|
s.Header = s.Header.Bold(true)
|
|
s.Selected = s.Selected.
|
|
Foreground(lipgloss.Color("0")).
|
|
Background(colorPrimary).
|
|
Bold(true)
|
|
return s
|
|
}
|
|
|
|
// setRows replaces a table's rows and keeps its cursor in a state the rest of
|
|
// this package can rely on: valid whenever the table has any rows at all.
|
|
//
|
|
// bubbles does not do that on its own. SetRows only clamps the cursor *down*
|
|
// (`if m.cursor > len(rows)-1`), so setting zero rows drives it to -1 and
|
|
// nothing ever brings it back — filling the table later leaves -1 in place,
|
|
// because -1 is not greater than len-1. Every table here is rebuilt from empty
|
|
// once at startup, when the first WindowSizeMsg arrives before any fetch has
|
|
// returned, so without this every cursor is -1 until the user happens to press
|
|
// up or down. Indexing a slice with that panics, which is exactly what
|
|
// assigning an on-call week did.
|
|
func setRows(t *table.Model, rows []table.Row) {
|
|
t.SetRows(rows)
|
|
if len(rows) > 0 && t.Cursor() < 0 {
|
|
t.SetCursor(0)
|
|
}
|
|
}
|
|
|
|
func (m *Model) rebuildIncidentTable() {
|
|
m.incidentTable.SetColumns(incidentColumns(m.width))
|
|
setRows(&m.incidentTable, incidentRows(m.incidents))
|
|
m.incidentTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildTable() {
|
|
m.alertTable.SetColumns(alertColumns(m.width))
|
|
setRows(&m.alertTable, alertRows(m.alerts))
|
|
m.alertTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildArchivedTable() {
|
|
m.archivedTable.SetColumns(incidentColumns(m.width))
|
|
setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
|
|
m.archivedTable.SetHeight(tableHeight(m.height, 8))
|
|
}
|
|
|
|
func (m *Model) rebuildScheduleTable() {
|
|
m.scheduleTable.SetColumns(scheduleColumns(m.width))
|
|
setRows(&m.scheduleTable, scheduleRows(m.scheduleDays))
|
|
m.scheduleTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func (m *Model) rebuildUserPickerTable() {
|
|
m.userPickerTable.SetColumns(userPickerColumns(m.width))
|
|
rows := make([]table.Row, len(m.users))
|
|
for i, u := range m.users {
|
|
rows[i] = table.Row{u.Username, u.Email}
|
|
}
|
|
setRows(&m.userPickerTable, rows)
|
|
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func (m *Model) rebuildUserManageTable() {
|
|
m.userManageTable.SetColumns(userManageColumns(m.width))
|
|
rows := make([]table.Row, len(m.users))
|
|
for i, u := range m.users {
|
|
topic := u.Topic()
|
|
if topic == "" {
|
|
topic = "—"
|
|
}
|
|
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
|
|
}
|
|
setRows(&m.userManageTable, rows)
|
|
m.userManageTable.SetHeight(tableHeight(m.height, 10))
|
|
}
|
|
|
|
func tableHeight(windowHeight, chrome int) int {
|
|
h := windowHeight - chrome
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m *Model) refreshDetailContent() {
|
|
if m.width == 0 {
|
|
return
|
|
}
|
|
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.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
|
|
}
|
|
|
|
func (m Model) statsViewportHeight() int {
|
|
h := m.height - 5
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m Model) detailViewportHeight() int {
|
|
h := m.height - 5
|
|
if m.mode == modeNote || m.mode == modeSnooze {
|
|
h -= 2
|
|
}
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
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
|
|
if nameW < 20 {
|
|
nameW = 20
|
|
}
|
|
// 10 = bubbles' Padding(0, 1) on each of the five cells.
|
|
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: "Incident", Width: incW},
|
|
}
|
|
}
|
|
|
|
func scheduleColumns(width int) []table.Column {
|
|
dateW := 18
|
|
onCallW := width - dateW - 6
|
|
if onCallW < 15 {
|
|
onCallW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Date", Width: dateW},
|
|
{Title: "On-Call", Width: onCallW},
|
|
}
|
|
}
|
|
|
|
func userPickerColumns(width int) []table.Column {
|
|
usernameW := 25
|
|
emailW := width - usernameW - 6
|
|
if emailW < 15 {
|
|
emailW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Username", Width: usernameW},
|
|
{Title: "Email", Width: emailW},
|
|
}
|
|
}
|
|
|
|
func userManageColumns(width int) []table.Column {
|
|
createdW := 12
|
|
usernameW := 25
|
|
topicW := 22
|
|
// 8 = bubbles' Padding(0, 1) on each of the four cells.
|
|
emailW := width - usernameW - topicW - createdW - 8
|
|
if emailW < 15 {
|
|
emailW = 15
|
|
}
|
|
return []table.Column{
|
|
{Title: "Username", Width: usernameW},
|
|
{Title: "Email", Width: emailW},
|
|
{Title: "Ntfy Topic", Width: topicW},
|
|
{Title: "Created", Width: createdW},
|
|
}
|
|
}
|
|
|
|
// ── 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 {
|
|
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
|
|
}
|
|
|
|
func scheduleRows(days []scheduleDay) []table.Row {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
rows := make([]table.Row, len(days))
|
|
for i, d := range days {
|
|
dateStr := d.date.Format("2006-01-02")
|
|
showWeek := i == 0 || d.date.Weekday() == time.Monday
|
|
_, week := d.date.ISOWeek()
|
|
weekPrefix := " "
|
|
if showWeek {
|
|
weekPrefix = fmt.Sprintf("W%02d ", week)
|
|
}
|
|
label := weekPrefix + d.date.Format("Jan 02 Mon")
|
|
if dateStr == today {
|
|
label = weekPrefix + "Today " + d.date.Format("Mon")
|
|
}
|
|
onCall := "—"
|
|
if d.entry != nil {
|
|
onCall = d.entry.Username
|
|
}
|
|
rows[i] = table.Row{label, onCall}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func buildScheduleDays(window time.Time, entries []api.ScheduleEntry) []scheduleDay {
|
|
entryMap := make(map[string]api.ScheduleEntry, len(entries))
|
|
for _, e := range entries {
|
|
entryMap[e.Date] = e
|
|
}
|
|
days := make([]scheduleDay, 7)
|
|
for i := range days {
|
|
date := window.AddDate(0, 0, i)
|
|
day := scheduleDay{date: date}
|
|
if e, ok := entryMap[date.Format("2006-01-02")]; ok {
|
|
e2 := e
|
|
day.entry = &e2
|
|
}
|
|
days[i] = day
|
|
}
|
|
return days
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
func humanAgo(now, t time.Time) string {
|
|
d := now.Sub(t)
|
|
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 "moments"
|
|
case d < time.Hour:
|
|
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", h)
|
|
}
|
|
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", days)
|
|
}
|
|
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 {
|
|
return func() tea.Msg {
|
|
if err := client.HealthCheck(); err != nil {
|
|
return connectErrMsg{err}
|
|
}
|
|
return connectedMsg{}
|
|
}
|
|
}
|
|
|
|
func fetchIncidentsCmd(client *api.Client, filter string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
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}
|
|
}
|
|
return alertsFetchedMsg{alerts}
|
|
}
|
|
}
|
|
|
|
func fetchStatsCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
incidents, err := client.GetIncidentStats()
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
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}
|
|
}
|
|
}
|
|
|
|
func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
alert, err := client.GetAlert(alertID)
|
|
if err != nil {
|
|
return detailErrMsg{err}
|
|
}
|
|
return alertDetailFetchedMsg{alert: *alert}
|
|
}
|
|
}
|
|
|
|
func fetchDetailStatsCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
top, err := client.GetTopAlerts(10)
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
byHour, err := client.GetStatsByHour()
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
byDay, err := client.GetStatsByDay()
|
|
if err != nil {
|
|
return detailStatsErrMsg{err}
|
|
}
|
|
return detailStatsFetchedMsg{top: top, byHour: byHour, byDay: byDay}
|
|
}
|
|
}
|
|
|
|
func fetchScheduleCmd(client *api.Client, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
|
if err != nil {
|
|
return scheduleFetchErrMsg{err}
|
|
}
|
|
current, err := client.GetCurrentOnCall()
|
|
if err != nil {
|
|
return scheduleFetchErrMsg{err}
|
|
}
|
|
return scheduleFetchedMsg{entries: entries, current: current}
|
|
}
|
|
}
|
|
|
|
func assignScheduleCmd(client *api.Client, userID int64, dates []string, replace bool, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.AssignSchedule(userID, dates, replace); err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
current, err := client.GetCurrentOnCall()
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
return scheduleFetchedMsg{entries: entries, current: current}
|
|
}
|
|
}
|
|
|
|
func deleteScheduleEntryCmd(client *api.Client, entryID int64, from, to time.Time) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteScheduleEntry(entryID); err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
entries, err := client.GetSchedule(from.Format("2006-01-02"), to.Format("2006-01-02"))
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
current, err := client.GetCurrentOnCall()
|
|
if err != nil {
|
|
return scheduleActionErrMsg{err}
|
|
}
|
|
return scheduleFetchedMsg{entries: entries, current: current}
|
|
}
|
|
}
|
|
|
|
func fetchUsersCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return usersFetchedMsg{} // empty on error, statusMsg set elsewhere
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func createUserCmd(client *api.Client, username, email string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.CreateUser(username, email); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
// setUserNotifyTargetCmd points a user's pages at a topic, or clears it when
|
|
// topic is empty. It re-lists afterwards so the table shows what the server
|
|
// stored rather than what was typed.
|
|
func setUserNotifyTargetCmd(client *api.Client, userID int64, topic string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if _, err := client.SetUserNotifyTarget(userID, topic); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func deleteUserCmd(client *api.Client, userID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteUser(userID); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
users, err := client.ListUsers()
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return usersFetchedMsg{users: users}
|
|
}
|
|
}
|
|
|
|
func createAPIKeyCmd(client *api.Client, userID int64, name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
key, err := client.CreateAPIKey(userID, name)
|
|
if err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return apiKeyCreatedMsg{key: *key}
|
|
}
|
|
}
|
|
|
|
func deleteAPIKeyCmd(client *api.Client, userID, keyID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.DeleteAPIKey(userID, keyID); err != nil {
|
|
return userActionErrMsg{err}
|
|
}
|
|
return apiKeyRevokedMsg{}
|
|
}
|
|
}
|
|
|
|
func tickCmd(interval time.Duration) tea.Cmd {
|
|
return tea.Tick(interval, func(t time.Time) tea.Msg {
|
|
return tickMsg(t)
|
|
})
|
|
}
|
|
|
|
func clearStatusCmd() tea.Cmd {
|
|
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
|
|
return clearStatusMsg{}
|
|
})
|
|
}
|