Files
uptop/internal/tui/tab_alerts.go
T
lerko a5fd3aec90
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
refactor: propagate context.Context through call chains
Thread context.Context from callers instead of creating
context.Background() at every call site. Engine stores its
lifecycle ctx for use by triggerAlert goroutines. TUI Model
carries ctx for store operations in Cmd closures. CLI commands
create a root ctx and thread it through openStore, seed
functions, and init methods.

Only two intentional context.Background() remain in non-root
positions: engine constructor default (overridden by Start)
and drainWrites (parent already cancelled at shutdown).
2026-06-27 19:43:44 -04:00

552 lines
16 KiB
Go

package tui
import (
"fmt"
neturl "net/url"
"sort"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type alertFormData struct {
Name string
AlertType string
WebhookURL string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
EmailFrom string
EmailTo string
NtfyURL string
NtfyTopic string
NtfyUser string
NtfyPass string
NtfyPri string
// Telegram
TelegramToken string
TelegramChatID string
// PagerDuty
PagerDutyKey string
PagerDutySeverity string
// Pushover
PushoverToken string
PushoverUser string
// Gotify
GotifyURL string
GotifyToken string
GotifyPriority string
// Opsgenie
OpsgenieAPIKey string
OpsgeniePriority string
OpsgenieEU bool
}
func fmtAlertType(t string) string {
switch t {
case "discord":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#5865F2")).Render(t)
case "slack":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#E01E5A")).Render(t)
case "webhook":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#F0E442")).Render(t)
case "email":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t)
case "ntfy":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t)
case "telegram":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#26A5E4")).Render(t)
case "pagerduty":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#06AC38")).Render(t)
case "pushover":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t)
case "gotify":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t)
case "opsgenie":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2684FF")).Render(t)
default:
return t
}
}
func (m Model) fmtAlertConfig(alert models.AlertConfig) string {
switch alert.Type {
case "email":
host := alert.Settings["host"]
to := alert.Settings["to"]
if host != "" && to != "" {
return limitStr(fmt.Sprintf("%s → %s", host, to), 34)
}
if host != "" {
return limitStr(host, 34)
}
return m.st.subtleStyle.Render("—")
case "ntfy":
topic := alert.Settings["topic"]
url := alert.Settings["url"]
if url != "" && topic != "" {
return limitStr(fmt.Sprintf("%s/%s", url, topic), 34)
}
return m.st.subtleStyle.Render("—")
case "telegram":
if id := alert.Settings["chat_id"]; id != "" {
return limitStr(fmt.Sprintf("chat:%s", id), 34)
}
return m.st.subtleStyle.Render("—")
case "pagerduty":
if key := alert.Settings["routing_key"]; key != "" {
return limitStr(maskSecret(key), 34)
}
return m.st.subtleStyle.Render("—")
case "pushover":
if user := alert.Settings["user"]; user != "" {
return limitStr(fmt.Sprintf("user:%s", maskSecret(user)), 34)
}
return m.st.subtleStyle.Render("—")
case "gotify":
// The gotify server URL identifies the target; the token is the
// secret and is never shown here.
if url := alert.Settings["url"]; url != "" {
return limitStr(url, 34)
}
return m.st.subtleStyle.Render("—")
case "opsgenie":
key := alert.Settings["api_key"]
if key != "" {
masked := maskSecret(key)
if alert.Settings["eu"] == "true" {
return limitStr(fmt.Sprintf("EU %s", masked), 34)
}
return limitStr(masked, 34)
}
return m.st.subtleStyle.Render("—")
default:
// discord/slack/webhook: the URL path IS the credential — show only
// enough to identify the target.
if val, ok := alert.Settings["url"]; ok && val != "" {
return limitStr(maskWebhookURL(val), 34)
}
return m.st.subtleStyle.Render("—")
}
}
// maskSecret keeps just enough of a credential to identify it.
func maskSecret(s string) string {
if len(s) > 8 {
return s[:4] + "…" + s[len(s)-4:]
}
return "●●●●●●●●"
}
// maskWebhookURL shows scheme and host only. For discord, slack, and generic
// webhooks the URL path carries the token, so the path is never rendered.
func maskWebhookURL(raw string) string {
u, err := neturl.Parse(raw)
if err != nil || u.Host == "" {
return "●●●●●●●●"
}
return u.Scheme + "://" + u.Host + "/…"
}
func (m Model) fmtAlertHealth(h monitor.AlertHealth) string {
if h.LastSendAt.IsZero() {
return m.st.subtleStyle.Render("●")
}
if h.LastSendOK {
return m.st.specialStyle.Render("●")
}
return m.st.dangerStyle.Render("●")
}
func (m Model) fmtAlertLastSent(h monitor.AlertHealth) string {
return m.fmtTimeAgo(h.LastSendAt)
}
func (m Model) viewAlertsTab() string {
if len(m.alerts) == 0 {
return m.emptyState("No alert channels configured.", "[n] Add your first alert")
}
var headers []string
var widths []int
if m.isWide() {
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"}
widths = []int{4, 3, 18, 12, 40, 12}
} else {
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "SENT"}
widths = []int{4, 3, 14, 10, 24, 8}
}
nameW := widths[2]
cfgW := widths[4]
tbl := m.renderTable(
headers,
len(m.alerts),
func(start, end int) [][]string {
var rows [][]string
for i := start; i < end; i++ {
a := m.alerts[i]
h := m.engine.GetAlertHealth(a.ID)
rows = append(rows, []string{
fmt.Sprintf("%d", i+1),
m.fmtAlertHealth(h),
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
fmtAlertType(a.Type),
limitStr(m.fmtAlertConfig(a), cfgW-2),
m.fmtAlertLastSent(h),
})
}
return rows
},
widths, nil,
)
var totalSent, totalFail int
for _, a := range m.alerts {
h := m.engine.GetAlertHealth(a.ID)
totalSent += h.SendCount
totalFail += h.FailCount
}
types := make(map[string]bool)
for _, a := range m.alerts {
types[a.Type] = true
}
failLabel := "failures"
if totalFail == 1 {
failLabel = "failure"
}
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
len(m.alerts), len(types), totalSent, totalFail, failLabel)
return tbl + "\n " + m.st.subtleStyle.Render(summary)
}
func (m Model) viewAlertDetailPanel() string {
if m.cursor >= len(m.alerts) {
return ""
}
a := m.alerts[m.cursor]
h := m.engine.GetAlertHealth(a.ID)
var b strings.Builder
b.WriteString(m.st.subtleStyle.Render(" Alerts > ") + m.st.titleStyle.Render(a.Name) + "\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
}
row("Type", fmtAlertType(a.Type))
if h.LastSendAt.IsZero() {
row("Health", m.st.subtleStyle.Render("never sent"))
} else if h.LastSendOK {
row("Health", m.st.specialStyle.Render("OK"))
} else {
row("Health", m.st.dangerStyle.Render("FAILED"))
}
if !h.LastSendAt.IsZero() {
row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+m.fmtAlertLastSent(h)+")")
}
if h.SendCount > 0 {
row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount))
}
if h.LastError != "" {
row("Last Error", m.st.dangerStyle.Render(limitStr(h.LastError, 60)))
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
// Render through the same allowlist the backup export uses — this panel
// ends up in screen shares and asciinema recordings. Keys are sorted so
// rows don't reshuffle every render.
redacted := models.RedactAlertSettings(a.Type, a.Settings)
keys := make([]string, 0, len(redacted))
for k := range redacted {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := redacted[k]
if v == "***REDACTED***" {
row(k, m.st.subtleStyle.Render("●●●●●●●●"))
continue
}
row(k, v)
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m *Model) initAlertHuhForm() tea.Cmd {
m.alertFormData = &alertFormData{
AlertType: "discord",
NtfyPri: "3",
PagerDutySeverity: "critical",
GotifyPriority: "5",
OpsgeniePriority: "P3",
}
if m.editID > 0 {
for _, alert := range m.alerts {
if alert.ID == m.editID {
m.alertFormData.Name = alert.Name
m.alertFormData.AlertType = alert.Type
if url, ok := alert.Settings["url"]; ok {
m.alertFormData.WebhookURL = url
}
switch alert.Type {
case "email":
m.alertFormData.SMTPHost = alert.Settings["host"]
m.alertFormData.SMTPPort = alert.Settings["port"]
m.alertFormData.SMTPUser = alert.Settings["user"]
m.alertFormData.SMTPPass = alert.Settings["pass"]
m.alertFormData.EmailFrom = alert.Settings["from"]
m.alertFormData.EmailTo = alert.Settings["to"]
case "ntfy":
m.alertFormData.NtfyURL = alert.Settings["url"]
m.alertFormData.NtfyTopic = alert.Settings["topic"]
m.alertFormData.NtfyUser = alert.Settings["username"]
m.alertFormData.NtfyPass = alert.Settings["password"]
m.alertFormData.NtfyPri = alert.Settings["priority"]
case "telegram":
m.alertFormData.TelegramToken = alert.Settings["token"]
m.alertFormData.TelegramChatID = alert.Settings["chat_id"]
case "pagerduty":
m.alertFormData.PagerDutyKey = alert.Settings["routing_key"]
m.alertFormData.PagerDutySeverity = alert.Settings["severity"]
case "pushover":
m.alertFormData.PushoverToken = alert.Settings["token"]
m.alertFormData.PushoverUser = alert.Settings["user"]
case "gotify":
m.alertFormData.GotifyURL = alert.Settings["url"]
m.alertFormData.GotifyToken = alert.Settings["token"]
m.alertFormData.GotifyPriority = alert.Settings["priority"]
case "opsgenie":
m.alertFormData.OpsgenieAPIKey = alert.Settings["api_key"]
m.alertFormData.OpsgeniePriority = alert.Settings["priority"]
m.alertFormData.OpsgenieEU = alert.Settings["eu"] == "true"
}
break
}
}
}
m.huhForm = huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Alert Name").
Placeholder("My Alert Channel").
Value(&m.alertFormData.Name).
Validate(func(s string) error {
if s == "" {
return fmt.Errorf("name is required")
}
return nil
}),
huh.NewSelect[string]().Title("Alert Type").
Options(
huh.NewOption("Discord", "discord"),
huh.NewOption("Slack", "slack"),
huh.NewOption("Webhook", "webhook"),
huh.NewOption("Email (SMTP)", "email"),
huh.NewOption("Ntfy", "ntfy"),
huh.NewOption("Telegram", "telegram"),
huh.NewOption("PagerDuty", "pagerduty"),
huh.NewOption("Pushover", "pushover"),
huh.NewOption("Gotify", "gotify"),
huh.NewOption("Opsgenie", "opsgenie"),
).Value(&m.alertFormData.AlertType),
).Title("Alert Config"),
huh.NewGroup(
huh.NewInput().Title("Webhook URL").
Placeholder("https://discord.com/api/webhooks/...").
Value(&m.alertFormData.WebhookURL),
).Title("Webhook").WithHideFunc(func() bool {
t := m.alertFormData.AlertType
return t != "discord" && t != "slack" && t != "webhook"
}),
huh.NewGroup(
huh.NewInput().Title("Ntfy Server URL").
Placeholder("https://ntfy.sh").
Value(&m.alertFormData.NtfyURL),
huh.NewInput().Title("Topic").
Placeholder("my-alerts").
Value(&m.alertFormData.NtfyTopic),
huh.NewSelect[string]().Title("Priority").
Options(
huh.NewOption("Min (1)", "1"),
huh.NewOption("Low (2)", "2"),
huh.NewOption("Default (3)", "3"),
huh.NewOption("High (4)", "4"),
huh.NewOption("Urgent (5)", "5"),
).Value(&m.alertFormData.NtfyPri),
huh.NewInput().Title("Username (optional)").
Placeholder("admin").
Value(&m.alertFormData.NtfyUser),
huh.NewInput().Title("Password (optional)").
EchoMode(huh.EchoModePassword).
Value(&m.alertFormData.NtfyPass),
).Title("Ntfy Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "ntfy"
}),
huh.NewGroup(
huh.NewInput().Title("SMTP Host").
Placeholder("smtp.gmail.com").
Value(&m.alertFormData.SMTPHost),
huh.NewInput().Title("SMTP Port").
Placeholder("587").
Value(&m.alertFormData.SMTPPort),
huh.NewInput().Title("SMTP User").
Placeholder("user@gmail.com").
Value(&m.alertFormData.SMTPUser),
huh.NewInput().Title("SMTP Password").
EchoMode(huh.EchoModePassword).
Value(&m.alertFormData.SMTPPass),
huh.NewInput().Title("From Email").
Placeholder("alerts@domain.com").
Value(&m.alertFormData.EmailFrom),
huh.NewInput().Title("To Email").
Placeholder("oncall@domain.com").
Value(&m.alertFormData.EmailTo),
).Title("Email Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "email"
}),
huh.NewGroup(
huh.NewInput().Title("Bot Token").
Placeholder("123456:ABC-DEF1234...").
Value(&m.alertFormData.TelegramToken),
huh.NewInput().Title("Chat ID").
Placeholder("-1001234567890").
Value(&m.alertFormData.TelegramChatID),
).Title("Telegram Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "telegram"
}),
huh.NewGroup(
huh.NewInput().Title("Routing Key").
Placeholder("your-integration-routing-key").
Value(&m.alertFormData.PagerDutyKey),
huh.NewSelect[string]().Title("Severity").
Options(
huh.NewOption("Critical", "critical"),
huh.NewOption("Error", "error"),
huh.NewOption("Warning", "warning"),
huh.NewOption("Info", "info"),
).Value(&m.alertFormData.PagerDutySeverity),
).Title("PagerDuty Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "pagerduty"
}),
huh.NewGroup(
huh.NewInput().Title("App Token").
Placeholder("your-pushover-app-token").
Value(&m.alertFormData.PushoverToken),
huh.NewInput().Title("User Key").
Placeholder("your-pushover-user-key").
Value(&m.alertFormData.PushoverUser),
).Title("Pushover Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "pushover"
}),
huh.NewGroup(
huh.NewInput().Title("Server URL").
Placeholder("https://gotify.example.com").
Value(&m.alertFormData.GotifyURL),
huh.NewInput().Title("App Token").
Placeholder("your-gotify-app-token").
Value(&m.alertFormData.GotifyToken),
huh.NewSelect[string]().Title("Priority").
Options(
huh.NewOption("Min (0)", "0"),
huh.NewOption("Low (2)", "2"),
huh.NewOption("Normal (5)", "5"),
huh.NewOption("High (8)", "8"),
).Value(&m.alertFormData.GotifyPriority),
).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify"
}),
huh.NewGroup(
huh.NewInput().Title("API Key").
Placeholder("your-opsgenie-api-key").
Value(&m.alertFormData.OpsgenieAPIKey),
huh.NewSelect[string]().Title("Priority").
Options(
huh.NewOption("Critical (P1)", "P1"),
huh.NewOption("High (P2)", "P2"),
huh.NewOption("Moderate (P3)", "P3"),
huh.NewOption("Low (P4)", "P4"),
huh.NewOption("Informational (P5)", "P5"),
).Value(&m.alertFormData.OpsgeniePriority),
huh.NewConfirm().Title("EU Instance?").
Value(&m.alertFormData.OpsgenieEU),
).Title("Opsgenie Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "opsgenie"
}),
).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init()
}
func (m *Model) submitAlertForm() tea.Cmd {
d := m.alertFormData
settings := make(map[string]string)
switch d.AlertType {
case "email":
settings["host"] = d.SMTPHost
settings["port"] = d.SMTPPort
settings["user"] = d.SMTPUser
settings["pass"] = d.SMTPPass
settings["from"] = d.EmailFrom
settings["to"] = d.EmailTo
case "ntfy":
settings["url"] = d.NtfyURL
settings["topic"] = d.NtfyTopic
settings["priority"] = d.NtfyPri
settings["username"] = d.NtfyUser
settings["password"] = d.NtfyPass
case "telegram":
settings["token"] = d.TelegramToken
settings["chat_id"] = d.TelegramChatID
case "pagerduty":
settings["routing_key"] = d.PagerDutyKey
settings["severity"] = d.PagerDutySeverity
case "pushover":
settings["token"] = d.PushoverToken
settings["user"] = d.PushoverUser
case "gotify":
settings["url"] = d.GotifyURL
settings["token"] = d.GotifyToken
settings["priority"] = d.GotifyPriority
case "opsgenie":
settings["api_key"] = d.OpsgenieAPIKey
settings["priority"] = d.OpsgeniePriority
if d.OpsgenieEU {
settings["eu"] = "true"
}
default:
settings["url"] = d.WebhookURL
}
st := m.store
ctx := m.ctx
id := m.editID
name, aType := d.Name, d.AlertType
m.state = stateDashboard
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(ctx, id, name, aType, settings)
})
}
return writeCmd("Add alert", func() error {
return st.AddAlert(ctx, name, aType, settings)
})
}