Files
terdut-tui/internal/tui/model.go
T
Niklas Ye 0006424eaf Act on the selected row, not the one the table moved to
In Users, pressing k on a user opened API keys for the user above it.
The dashboard hands every key to the section's table before the
section's own handler reads the cursor, and bubbles' table claims
several letters for navigation: k is up, d half a page down, f a page
down. A letter that is also an action therefore moved the cursor first,
and the action landed on the row it had moved to. With your own user
first in the list, k looked like it only ever showed your own keys.

The same collision hit two destructive keys:

- d in Users asked to delete a user half a page below the selected one.
  The confirmation names the user, and that was the only thing standing
  between a keypress and deleting the wrong person.
- d in Schedule targeted a different day's assignment the same way.

f in Incidents and Alerts cycled the filter and paged the cursor down
too, which was harmless but wrong.

Each table now gives up exactly the letters its section acts on,
through tableKeyMap. The arrow keys and every other default binding are
untouched. The cost is that k no longer moves up in Users, where it
means API keys, as the README has always said. The up arrow still
works, and the README now says to use it there.

users_test.go reproduces all four. With tableKeyMap reverted to the
defaults, each of them fails exactly as reported.
2026-09-19 21:09:06 +02:00

1050 lines
29 KiB
Go

package tui
import (
"fmt"
"slices"
"time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
)
// ── 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
styles Styles
}
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
st := newStyles(th)
ts := st.Table()
// Each table sees a key before the section's own handler does, so any
// key a section uses as an action must be taken out of that table's
// navigation bindings, or the cursor moves first and the action lands on
// a different row. See tableKeyMap.
incidentT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
incidentT.SetStyles(ts)
alertT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("f")))
alertT.SetStyles(ts)
archivedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
archivedT.SetStyles(ts)
schedT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d")))
schedT.SetStyles(ts)
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k")))
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
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
helpModel := help.New()
helpModel.Styles = st.Help()
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: helpModel,
keys: keys,
styles: st,
}
}
func (m Model) Init() tea.Cmd {
return connectCmd(m.client)
}
// ── Table rebuilders ───────────────────────────────────────────────────────
// tableKeyMap is the bubbles table keymap without the given keys.
//
// The table's defaults claim several letters -- k up, d half a page down, f a
// page down -- and the dashboard hands every key to the table before the
// section's own handler reads the cursor. A letter that is both, like k for
// API keys in Users, therefore moved the cursor and then acted on the row it
// had moved to. Each table gives up the letters its section acts on; the
// arrow keys and the rest of the defaults are untouched.
func tableKeyMap(reserved ...string) table.KeyMap {
km := table.DefaultKeyMap()
for _, b := range []*key.Binding{
&km.LineUp, &km.LineDown, &km.PageUp, &km.PageDown,
&km.HalfPageUp, &km.HalfPageDown, &km.GotoTop, &km.GotoBottom,
} {
var keep []string
for _, k := range b.Keys() {
if !slices.Contains(reserved, k) {
keep = append(keep, k)
}
}
b.SetKeys(keep...)
}
return km
}
// 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.styles, m.selectedAlert, m.width))
return
}
m.detailViewport.SetContent(
buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width))
}
func (m *Model) refreshStatsContent() {
m.statsViewport.SetContent(
buildStatsContent(m.styles, 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{}
})
}