b5ee62f145
Press enter on any alert to open a full-screen detail pane (viewport). Keybindings in detail mode: a/A acknowledge / unacknowledge (updates immediately) c compose a comment (text input at bottom) [/] cycle comment cursor up/down d delete selected comment (y/N confirmation) s assign placeholder — shows "not yet supported by server" S open stats view: top alerts + by-hour/by-day ASCII bar charts esc back to dashboard API additions: GetAlert, AcknowledgeAlert, UnacknowledgeAlert, GetComments, AddComment, DeleteComment, GetTopAlerts, GetStatsByHour, GetStatsByDay.
369 lines
8.2 KiB
Go
369 lines
8.2 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"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"
|
|
"github.com/yeniklas/terdut-tui/internal/api"
|
|
)
|
|
|
|
type section int
|
|
|
|
const (
|
|
sectionAlerts section = iota
|
|
sectionSchedule
|
|
sectionUsers
|
|
)
|
|
|
|
type mode int
|
|
|
|
const (
|
|
modeDashboard mode = iota
|
|
modeDetail
|
|
modeComment
|
|
modeConfirmDelete
|
|
modeStats
|
|
)
|
|
|
|
// tea.Msg types — dashboard
|
|
|
|
type connectedMsg struct{}
|
|
type connectErrMsg struct{ err error }
|
|
type alertsFetchedMsg struct{ alerts []api.Alert }
|
|
type statsFetchedMsg struct{ stats api.AlertStats }
|
|
type fetchDataErrMsg struct{ err error }
|
|
type tickMsg time.Time
|
|
type clearStatusMsg struct{}
|
|
|
|
// tea.Msg types — detail
|
|
|
|
type alertDetailFetchedMsg struct {
|
|
alert api.Alert
|
|
comments []api.Comment
|
|
}
|
|
type alertDetailErrMsg 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 }
|
|
|
|
// Model holds all UI state.
|
|
|
|
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
|
|
alerts []api.Alert
|
|
stats *api.AlertStats
|
|
filterStatus string
|
|
alertTable table.Model
|
|
|
|
// Detail view
|
|
selectedAlert api.Alert
|
|
comments []api.Comment
|
|
commentCursor int
|
|
detailLoading bool
|
|
detailViewport viewport.Model
|
|
|
|
// Comment compose
|
|
commentInput textinput.Model
|
|
|
|
// Confirm delete
|
|
pendingDeleteID int64
|
|
|
|
// Stats view
|
|
topAlerts []api.TopAlert
|
|
hourStats []api.HourStat
|
|
dayStats []api.DayStat
|
|
statsLoading bool
|
|
statsViewport viewport.Model
|
|
|
|
help help.Model
|
|
keys keyMap
|
|
}
|
|
|
|
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
|
|
t := table.New(table.WithFocused(true))
|
|
s := table.DefaultStyles()
|
|
s.Header = s.Header.Bold(true)
|
|
s.Selected = s.Selected.
|
|
Foreground(lipgloss.Color("0")).
|
|
Background(colorPrimary).
|
|
Bold(true)
|
|
t.SetStyles(s)
|
|
|
|
ti := textinput.New()
|
|
ti.Placeholder = "type your comment…"
|
|
ti.CharLimit = 1000
|
|
|
|
return Model{
|
|
client: client,
|
|
serverURL: serverURL,
|
|
refreshInterval: refreshInterval,
|
|
activeSection: sectionAlerts,
|
|
mode: modeDashboard,
|
|
loading: true,
|
|
filterStatus: "firing",
|
|
commentCursor: -1,
|
|
alertTable: t,
|
|
commentInput: ti,
|
|
help: help.New(),
|
|
keys: keys,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return connectCmd(m.client)
|
|
}
|
|
|
|
// rebuildTable updates alert table columns, rows, and height from current state.
|
|
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)
|
|
}
|
|
|
|
// refreshDetailContent rebuilds the viewport content from selectedAlert + comments.
|
|
func (m *Model) refreshDetailContent() {
|
|
if m.width == 0 {
|
|
return
|
|
}
|
|
content := buildDetailContent(m.selectedAlert, m.comments, m.commentCursor, m.width)
|
|
m.detailViewport.SetContent(content)
|
|
}
|
|
|
|
// refreshStatsContent rebuilds the stats viewport from loaded stats data.
|
|
func (m *Model) refreshStatsContent() {
|
|
content := buildStatsContent(m.topAlerts, m.hourStats, m.dayStats, m.width)
|
|
m.statsViewport.SetContent(content)
|
|
}
|
|
|
|
// detailViewportHeight returns the detail viewport height for the current mode.
|
|
func (m Model) detailViewportHeight() int {
|
|
h := m.height - 5
|
|
if m.mode == modeComment {
|
|
h -= 2 // separator + input line
|
|
}
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
func alertColumns(width int) []table.Column {
|
|
nameW := width/2 - 8
|
|
if nameW < 20 {
|
|
nameW = 20
|
|
}
|
|
ackW := width - nameW - 10 - 12 - 6
|
|
if ackW < 8 {
|
|
ackW = 8
|
|
}
|
|
return []table.Column{
|
|
{Title: "Name", Width: nameW},
|
|
{Title: "Status", Width: 10},
|
|
{Title: "Started", Width: 12},
|
|
{Title: "Ack By", Width: ackW},
|
|
}
|
|
}
|
|
|
|
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), a.AcknowledgedBy}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func humanAgo(now, t time.Time) string {
|
|
d := now.Sub(t)
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", 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 %dm ago", 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 %dh ago", days, h)
|
|
}
|
|
}
|
|
|
|
// Command constructors
|
|
|
|
func connectCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if err := client.HealthCheck(); err != nil {
|
|
return connectErrMsg{err}
|
|
}
|
|
return connectedMsg{}
|
|
}
|
|
}
|
|
|
|
func fetchAlertsCmd(client *api.Client, status string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
alerts, err := client.ListAlerts(status, 500)
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return alertsFetchedMsg{alerts}
|
|
}
|
|
}
|
|
|
|
func fetchStatsCmd(client *api.Client) tea.Cmd {
|
|
return func() tea.Msg {
|
|
stats, err := client.GetAlertStats()
|
|
if err != nil {
|
|
return fetchDataErrMsg{err}
|
|
}
|
|
return statsFetchedMsg{*stats}
|
|
}
|
|
}
|
|
|
|
func fetchAlertDetailCmd(client *api.Client, alertID int64) tea.Cmd {
|
|
return func() tea.Msg {
|
|
alert, err := client.GetAlert(alertID)
|
|
if err != nil {
|
|
return alertDetailErrMsg{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}
|
|
}
|
|
}
|
|
|
|
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 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{}
|
|
})
|
|
}
|