refactor(tui): decompose god files into single-concern modules
tui.go (1032→164) and tab_sites.go (993→482) violated "small functions" and "testable in isolation" standards. Extracted 6 new files by concern: - format.go: pure formatting functions (fmtLatency, fmtUptime, etc.) - sparkline.go: sparkline rendering (latency, heartbeat, group) - update.go: Update method decomposed into 15 named handlers - view_dashboard.go: View, dashboard composition, tab bar, footer - view_detail.go: site detail panel - data.go: data refresh with extracted sortSitesForDisplay/filterSites Added 17 unit tests for the newly-testable pure functions covering format, sparkline, sort ordering, and filter logic. No behavioral changes — strict move-and-extract refactor.
This commit was merged in pull request #53.
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadCollapsed(s store.Store) map[int]bool {
|
||||||
|
m := make(map[int]bool)
|
||||||
|
raw, err := s.GetPreference("collapsed_groups")
|
||||||
|
if err != nil || raw == "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
var ids []int
|
||||||
|
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
m[id] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveCollapsed(s store.Store, collapsed map[int]bool) {
|
||||||
|
var ids []int
|
||||||
|
for id, v := range collapsed {
|
||||||
|
if v {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(ids)
|
||||||
|
_ = s.SetPreference("collapsed_groups", string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site {
|
||||||
|
var groups, ungrouped []models.Site
|
||||||
|
children := make(map[int][]models.Site)
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.Type == "group" {
|
||||||
|
groups = append(groups, s)
|
||||||
|
} else if s.ParentID > 0 {
|
||||||
|
children[s.ParentID] = append(children[s.ParentID], s)
|
||||||
|
} else {
|
||||||
|
ungrouped = append(ungrouped, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
||||||
|
for pid := range children {
|
||||||
|
c := children[pid]
|
||||||
|
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
||||||
|
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
|
||||||
|
children[pid] = c
|
||||||
|
}
|
||||||
|
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
||||||
|
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
|
||||||
|
|
||||||
|
var ordered []models.Site
|
||||||
|
for _, g := range groups {
|
||||||
|
ordered = append(ordered, g)
|
||||||
|
if !collapsed[g.ID] {
|
||||||
|
ordered = append(ordered, children[g.ID]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered = append(ordered, ungrouped...)
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterSites(sites []models.Site, needle string) []models.Site {
|
||||||
|
lower := strings.ToLower(needle)
|
||||||
|
var filtered []models.Site
|
||||||
|
for _, s := range sites {
|
||||||
|
if strings.Contains(strings.ToLower(s.Name), lower) {
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshData() {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
ordered := sortSitesForDisplay(allSites, m.collapsed)
|
||||||
|
if m.filterText != "" {
|
||||||
|
ordered = filterSites(ordered, m.filterText)
|
||||||
|
}
|
||||||
|
m.sites = ordered
|
||||||
|
|
||||||
|
if alerts, err := m.store.GetAllAlerts(); err == nil {
|
||||||
|
m.alerts = alerts
|
||||||
|
}
|
||||||
|
if m.isAdmin {
|
||||||
|
if users, err := m.store.GetAllUsers(); err == nil {
|
||||||
|
m.users = users
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes, err := m.store.GetAllNodes(); err == nil {
|
||||||
|
m.nodes = nodes
|
||||||
|
}
|
||||||
|
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
||||||
|
m.maintenanceWindows = windows
|
||||||
|
}
|
||||||
|
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
||||||
|
|
||||||
|
listLen := len(m.sites)
|
||||||
|
switch m.currentTab {
|
||||||
|
case 1:
|
||||||
|
listLen = len(m.alerts)
|
||||||
|
case 3:
|
||||||
|
listLen = len(m.nodes)
|
||||||
|
case 4:
|
||||||
|
listLen = len(m.maintenanceWindows)
|
||||||
|
case 5:
|
||||||
|
listLen = len(m.users)
|
||||||
|
}
|
||||||
|
if listLen > 0 && m.cursor >= listLen {
|
||||||
|
m.cursor = listLen - 1
|
||||||
|
}
|
||||||
|
if m.cursor < m.tableOffset {
|
||||||
|
m.tableOffset = m.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
|
||||||
|
sites := []models.Site{
|
||||||
|
{ID: 3, Name: "ungrouped", Type: "http", Status: "UP"},
|
||||||
|
{ID: 1, Name: "group-a", Type: "group", Status: "UP"},
|
||||||
|
{ID: 2, Name: "child", Type: "http", Status: "UP", ParentID: 1},
|
||||||
|
}
|
||||||
|
result := sortSitesForDisplay(sites, nil)
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Fatalf("expected 3 sites, got %d", len(result))
|
||||||
|
}
|
||||||
|
if result[0].Name != "group-a" {
|
||||||
|
t.Errorf("first should be group, got %s", result[0].Name)
|
||||||
|
}
|
||||||
|
if result[1].Name != "child" {
|
||||||
|
t.Errorf("second should be child, got %s", result[1].Name)
|
||||||
|
}
|
||||||
|
if result[2].Name != "ungrouped" {
|
||||||
|
t.Errorf("third should be ungrouped, got %s", result[2].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
|
||||||
|
sites := []models.Site{
|
||||||
|
{ID: 1, Name: "group-a", Type: "group", Status: "UP"},
|
||||||
|
{ID: 2, Name: "child-1", Type: "http", Status: "UP", ParentID: 1},
|
||||||
|
{ID: 3, Name: "child-2", Type: "http", Status: "UP", ParentID: 1},
|
||||||
|
}
|
||||||
|
collapsed := map[int]bool{1: true}
|
||||||
|
result := sortSitesForDisplay(sites, collapsed)
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("collapsed group should hide children, got %d items", len(result))
|
||||||
|
}
|
||||||
|
if result[0].Name != "group-a" {
|
||||||
|
t.Errorf("only group should remain, got %s", result[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
|
||||||
|
sites := []models.Site{
|
||||||
|
{ID: 1, Name: "up-site", Type: "http", Status: "UP"},
|
||||||
|
{ID: 2, Name: "down-site", Type: "http", Status: "DOWN"},
|
||||||
|
{ID: 3, Name: "late-site", Type: "http", Status: "LATE"},
|
||||||
|
}
|
||||||
|
result := sortSitesForDisplay(sites, nil)
|
||||||
|
if result[0].Status != "DOWN" {
|
||||||
|
t.Errorf("DOWN should sort first, got %s", result[0].Status)
|
||||||
|
}
|
||||||
|
if result[1].Status != "LATE" {
|
||||||
|
t.Errorf("LATE should sort second, got %s", result[1].Status)
|
||||||
|
}
|
||||||
|
if result[2].Status != "UP" {
|
||||||
|
t.Errorf("UP should sort third, got %s", result[2].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSites(t *testing.T) {
|
||||||
|
sites := []models.Site{
|
||||||
|
{Name: "Production API"},
|
||||||
|
{Name: "Staging API"},
|
||||||
|
{Name: "Database"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
needle string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"api", 2},
|
||||||
|
{"API", 2},
|
||||||
|
{"database", 1},
|
||||||
|
{"nonexistent", 0},
|
||||||
|
{"", 3},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := filterSites(sites, tt.needle)
|
||||||
|
if len(got) != tt.want {
|
||||||
|
t.Errorf("filterSites(%q) returned %d, want %d", tt.needle, len(got), tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSites_EmptyNeedle(t *testing.T) {
|
||||||
|
sites := []models.Site{{Name: "a"}, {Name: "b"}}
|
||||||
|
got := filterSites(sites, "")
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Errorf("empty needle should return all, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func limitStr(text string, max int) string {
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) > max {
|
||||||
|
return string(runes[:max-3]) + "..."
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func siteOrder(s models.Site) int {
|
||||||
|
if s.Paused {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
switch s.Status {
|
||||||
|
case "DOWN", "SSL EXP":
|
||||||
|
return 0
|
||||||
|
case "LATE":
|
||||||
|
return 1
|
||||||
|
case "PENDING":
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeIcon(siteType string, collapsed bool) string {
|
||||||
|
switch siteType {
|
||||||
|
case "http":
|
||||||
|
return "→"
|
||||||
|
case "push":
|
||||||
|
return "↓"
|
||||||
|
case "ping":
|
||||||
|
return "↔"
|
||||||
|
case "port":
|
||||||
|
return "⊡"
|
||||||
|
case "dns":
|
||||||
|
return "◆"
|
||||||
|
case "group":
|
||||||
|
if collapsed {
|
||||||
|
return "▶"
|
||||||
|
}
|
||||||
|
return "▼"
|
||||||
|
default:
|
||||||
|
return "·"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtLatency(d time.Duration) string {
|
||||||
|
ms := d.Milliseconds()
|
||||||
|
if ms == 0 {
|
||||||
|
return subtleStyle.Render("—")
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if ms < 1000 {
|
||||||
|
s = fmt.Sprintf("%dms", ms)
|
||||||
|
} else {
|
||||||
|
s = fmt.Sprintf("%.1fs", float64(ms)/1000)
|
||||||
|
}
|
||||||
|
if ms < 200 {
|
||||||
|
return specialStyle.Render(s)
|
||||||
|
}
|
||||||
|
if ms < 500 {
|
||||||
|
return warnStyle.Render(s)
|
||||||
|
}
|
||||||
|
return dangerStyle.Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtUptime(statuses []bool) string {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return subtleStyle.Render("—")
|
||||||
|
}
|
||||||
|
up := 0
|
||||||
|
for _, s := range statuses {
|
||||||
|
if s {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pct := float64(up) / float64(len(statuses)) * 100
|
||||||
|
s := fmt.Sprintf("%.1f%%", pct)
|
||||||
|
if pct >= 99 {
|
||||||
|
return specialStyle.Render(s)
|
||||||
|
}
|
||||||
|
if pct >= 95 {
|
||||||
|
return warnStyle.Render(s)
|
||||||
|
}
|
||||||
|
return dangerStyle.Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtSSL(site models.Site) string {
|
||||||
|
if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
|
||||||
|
return subtleStyle.Render("-")
|
||||||
|
}
|
||||||
|
days := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||||
|
s := fmt.Sprintf("%dd", days)
|
||||||
|
if days <= 0 {
|
||||||
|
return dangerStyle.Render("EXPIRED")
|
||||||
|
}
|
||||||
|
if days <= site.ExpiryThreshold {
|
||||||
|
return warnStyle.Render(s)
|
||||||
|
}
|
||||||
|
return specialStyle.Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtRetries(site models.Site) string {
|
||||||
|
retriesDone := site.FailureCount - 1
|
||||||
|
if retriesDone < 0 {
|
||||||
|
retriesDone = 0
|
||||||
|
}
|
||||||
|
dispCount := retriesDone
|
||||||
|
if dispCount > site.MaxRetries {
|
||||||
|
dispCount = site.MaxRetries
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
|
||||||
|
if site.Status == "DOWN" {
|
||||||
|
return dangerStyle.Render(s)
|
||||||
|
}
|
||||||
|
if site.Status == "UP" && site.FailureCount > 0 {
|
||||||
|
return warnStyle.Render(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtStatus(status string, paused bool, inMaint bool) string {
|
||||||
|
if paused {
|
||||||
|
return warnStyle.Render("PAUSED")
|
||||||
|
}
|
||||||
|
if inMaint {
|
||||||
|
return maintStyle.Render("MAINT")
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "DOWN", "SSL EXP":
|
||||||
|
return dangerStyle.Render(status)
|
||||||
|
case "LATE":
|
||||||
|
return warnStyle.Render(status)
|
||||||
|
case "PENDING":
|
||||||
|
return subtleStyle.Render(status)
|
||||||
|
default:
|
||||||
|
return specialStyle.Render(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%dm", int(d.Minutes()))
|
||||||
|
}
|
||||||
|
if d < 24*time.Hour {
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if m > 0 {
|
||||||
|
return fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh", h)
|
||||||
|
}
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
hours := int(d.Hours()) % 24
|
||||||
|
if hours > 0 {
|
||||||
|
return fmt.Sprintf("%dd %dh", days, hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dd", days)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
applyTheme(themeFlexokiDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitStr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
max int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"hello", 10, "hello"},
|
||||||
|
{"hello", 5, "hello"},
|
||||||
|
{"hello world", 8, "hello..."},
|
||||||
|
{"", 5, ""},
|
||||||
|
{"abc", 3, "abc"},
|
||||||
|
{"abcd", 3, "..."},
|
||||||
|
{"日本語テスト", 4, "日..."},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := limitStr(tt.input, tt.max)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("limitStr(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSiteOrder(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
site models.Site
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"down", models.Site{Status: "DOWN"}, 0},
|
||||||
|
{"ssl exp", models.Site{Status: "SSL EXP"}, 0},
|
||||||
|
{"late", models.Site{Status: "LATE"}, 1},
|
||||||
|
{"up", models.Site{Status: "UP"}, 2},
|
||||||
|
{"pending", models.Site{Status: "PENDING"}, 3},
|
||||||
|
{"paused up", models.Site{Status: "UP", Paused: true}, 3},
|
||||||
|
{"paused down", models.Site{Status: "DOWN", Paused: true}, 3},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := siteOrder(tt.site)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("siteOrder(%s) = %d, want %d", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFmtDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
d time.Duration
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{30 * time.Second, "30s"},
|
||||||
|
{5 * time.Minute, "5m"},
|
||||||
|
{2*time.Hour + 30*time.Minute, "2h 30m"},
|
||||||
|
{2 * time.Hour, "2h"},
|
||||||
|
{25 * time.Hour, "1d 1h"},
|
||||||
|
{48 * time.Hour, "2d"},
|
||||||
|
{49 * time.Hour, "2d 1h"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := fmtDuration(tt.d)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("fmtDuration(%v) = %q, want %q", tt.d, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeIcon(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
siteType string
|
||||||
|
collapsed bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"http", false, "→"},
|
||||||
|
{"push", false, "↓"},
|
||||||
|
{"ping", false, "↔"},
|
||||||
|
{"port", false, "⊡"},
|
||||||
|
{"dns", false, "◆"},
|
||||||
|
{"group", false, "▼"},
|
||||||
|
{"group", true, "▶"},
|
||||||
|
{"unknown", false, "·"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := typeIcon(tt.siteType, tt.collapsed)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("typeIcon(%q, %v) = %q, want %q", tt.siteType, tt.collapsed, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFmtUptime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statuses []bool
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{"empty", nil, "—"},
|
||||||
|
{"all up", []bool{true, true, true, true}, "100.0%"},
|
||||||
|
{"half", []bool{true, false, true, false}, "50.0%"},
|
||||||
|
{"all down", []bool{false, false}, "0.0%"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := fmtUptime(tt.statuses)
|
||||||
|
if !containsPlain(got, tt.wantSub) {
|
||||||
|
t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFmtLatency(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
d time.Duration
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{0, "—"},
|
||||||
|
{50 * time.Millisecond, "50ms"},
|
||||||
|
{300 * time.Millisecond, "300ms"},
|
||||||
|
{1500 * time.Millisecond, "1.5s"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := fmtLatency(tt.d)
|
||||||
|
if !containsPlain(got, tt.wantSub) {
|
||||||
|
t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsPlain(styled, sub string) bool {
|
||||||
|
// ANSI-styled strings contain the substring somewhere
|
||||||
|
return len(styled) > 0 && contains(styled, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(sub) == 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||||
|
|
||||||
|
func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
|
||||||
|
if len(latencies) == 0 {
|
||||||
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := latencies
|
||||||
|
sampledStatuses := statuses
|
||||||
|
if len(samples) > width {
|
||||||
|
samples = samples[len(samples)-width:]
|
||||||
|
if len(sampledStatuses) > width {
|
||||||
|
sampledStatuses = sampledStatuses[len(sampledStatuses)-width:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
minL, maxL := samples[0], samples[0]
|
||||||
|
for _, l := range samples {
|
||||||
|
if l < minL {
|
||||||
|
minL = l
|
||||||
|
}
|
||||||
|
if l > maxL {
|
||||||
|
maxL = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
|
spread := maxL - minL
|
||||||
|
for i, l := range samples {
|
||||||
|
idx := 0
|
||||||
|
if spread > 0 {
|
||||||
|
idx = int(float64(l-minL) / float64(spread) * 7)
|
||||||
|
if idx > 7 {
|
||||||
|
idx = 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch := string(sparkChars[idx])
|
||||||
|
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
||||||
|
if isDown {
|
||||||
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
|
} else {
|
||||||
|
ms := l.Milliseconds()
|
||||||
|
if ms < 200 {
|
||||||
|
sb.WriteString(specialStyle.Render(ch))
|
||||||
|
} else if ms < 500 {
|
||||||
|
sb.WriteString(warnStyle.Render(ch))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func heartbeatSparkline(statuses []bool, width int) string {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
|
}
|
||||||
|
|
||||||
|
samples := statuses
|
||||||
|
if len(samples) > width {
|
||||||
|
samples = samples[len(samples)-width:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(samples); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
|
for _, up := range samples {
|
||||||
|
if up {
|
||||||
|
sb.WriteString(specialStyle.Render("▁"))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(dangerStyle.Render("█"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) groupSparkline(groupID int, width int) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
var childStatuses [][]bool
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
childStatuses = append(childStatuses, hist.Statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(childStatuses) == 0 {
|
||||||
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
|
}
|
||||||
|
|
||||||
|
maxLen := 0
|
||||||
|
for _, s := range childStatuses {
|
||||||
|
if len(s) > maxLen {
|
||||||
|
maxLen = len(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxLen > width {
|
||||||
|
maxLen = width
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated := make([]bool, maxLen)
|
||||||
|
for i := 0; i < maxLen; i++ {
|
||||||
|
allUp := true
|
||||||
|
for _, statuses := range childStatuses {
|
||||||
|
idx := len(statuses) - maxLen + i
|
||||||
|
if idx >= 0 && !statuses[idx] {
|
||||||
|
allUp = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aggregated[i] = allUp
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if remaining := width - len(aggregated); remaining > 0 {
|
||||||
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
|
}
|
||||||
|
for _, up := range aggregated {
|
||||||
|
if up {
|
||||||
|
sb.WriteString(specialStyle.Render("●"))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(dangerStyle.Render("●"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) groupUptime(groupID int) string {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
var allStatuses [][]bool
|
||||||
|
for _, s := range allSites {
|
||||||
|
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
||||||
|
hist, _ := m.engine.GetHistory(s.ID)
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
allStatuses = append(allStatuses, hist.Statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(allStatuses) == 0 {
|
||||||
|
return subtleStyle.Render("—")
|
||||||
|
}
|
||||||
|
total, up := 0, 0
|
||||||
|
for _, statuses := range allStatuses {
|
||||||
|
for _, s := range statuses {
|
||||||
|
total++
|
||||||
|
if s {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmtUptime(func() []bool {
|
||||||
|
out := make([]bool, total)
|
||||||
|
idx := 0
|
||||||
|
for _, statuses := range allStatuses {
|
||||||
|
copy(out[idx:], statuses)
|
||||||
|
idx += len(statuses)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}())
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLatencySparkline_Empty(t *testing.T) {
|
||||||
|
got := latencySparkline(nil, nil, 10)
|
||||||
|
if !strings.Contains(got, "··········") {
|
||||||
|
t.Errorf("empty sparkline should be dots, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencySparkline_SingleValue(t *testing.T) {
|
||||||
|
latencies := []time.Duration{100 * time.Millisecond}
|
||||||
|
statuses := []bool{true}
|
||||||
|
got := latencySparkline(latencies, statuses, 5)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Error("sparkline should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
||||||
|
latencies := make([]time.Duration, 20)
|
||||||
|
statuses := make([]bool, 20)
|
||||||
|
for i := range latencies {
|
||||||
|
latencies[i] = time.Duration(i*50) * time.Millisecond
|
||||||
|
statuses[i] = true
|
||||||
|
}
|
||||||
|
got := latencySparkline(latencies, statuses, 5)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Error("sparkline should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||||
|
got := heartbeatSparkline(nil, 10)
|
||||||
|
if !strings.Contains(got, "··········") {
|
||||||
|
t.Errorf("empty heartbeat should be dots, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||||
|
statuses := []bool{true, false, true, true, false}
|
||||||
|
got := heartbeatSparkline(statuses, 5)
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Error("heartbeat sparkline should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
||||||
|
statuses := []bool{true, true}
|
||||||
|
got := heartbeatSparkline(statuses, 5)
|
||||||
|
if !strings.Contains(got, "···") {
|
||||||
|
t.Errorf("should have dot padding for width > data, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,40 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
|
||||||
|
|
||||||
func typeIcon(siteType string, collapsed bool) string {
|
|
||||||
switch siteType {
|
|
||||||
case "http":
|
|
||||||
return "→"
|
|
||||||
case "push":
|
|
||||||
return "↓"
|
|
||||||
case "ping":
|
|
||||||
return "↔"
|
|
||||||
case "port":
|
|
||||||
return "⊡"
|
|
||||||
case "dns":
|
|
||||||
return "◆"
|
|
||||||
case "group":
|
|
||||||
if collapsed {
|
|
||||||
return "▶"
|
|
||||||
}
|
|
||||||
return "▼"
|
|
||||||
default:
|
|
||||||
return "·"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var siteGroupStyle lipgloss.Style
|
var siteGroupStyle lipgloss.Style
|
||||||
|
|
||||||
type siteFormData struct {
|
type siteFormData struct {
|
||||||
@@ -60,289 +33,6 @@ type siteFormData struct {
|
|||||||
Regions string
|
Regions string
|
||||||
}
|
}
|
||||||
|
|
||||||
func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
|
|
||||||
if len(latencies) == 0 {
|
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
|
||||||
}
|
|
||||||
|
|
||||||
samples := latencies
|
|
||||||
sampledStatuses := statuses
|
|
||||||
if len(samples) > width {
|
|
||||||
samples = samples[len(samples)-width:]
|
|
||||||
if len(sampledStatuses) > width {
|
|
||||||
sampledStatuses = sampledStatuses[len(sampledStatuses)-width:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
minL, maxL := samples[0], samples[0]
|
|
||||||
for _, l := range samples {
|
|
||||||
if l < minL {
|
|
||||||
minL = l
|
|
||||||
}
|
|
||||||
if l > maxL {
|
|
||||||
maxL = l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
|
||||||
}
|
|
||||||
spread := maxL - minL
|
|
||||||
for i, l := range samples {
|
|
||||||
idx := 0
|
|
||||||
if spread > 0 {
|
|
||||||
idx = int(float64(l-minL) / float64(spread) * 7)
|
|
||||||
if idx > 7 {
|
|
||||||
idx = 7
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ch := string(sparkChars[idx])
|
|
||||||
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
|
||||||
if isDown {
|
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
|
||||||
} else {
|
|
||||||
ms := l.Milliseconds()
|
|
||||||
if ms < 200 {
|
|
||||||
sb.WriteString(specialStyle.Render(ch))
|
|
||||||
} else if ms < 500 {
|
|
||||||
sb.WriteString(warnStyle.Render(ch))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func heartbeatSparkline(statuses []bool, width int) string {
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
|
||||||
}
|
|
||||||
|
|
||||||
samples := statuses
|
|
||||||
if len(samples) > width {
|
|
||||||
samples = samples[len(samples)-width:]
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
if remaining := width - len(samples); remaining > 0 {
|
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
|
||||||
}
|
|
||||||
for _, up := range samples {
|
|
||||||
if up {
|
|
||||||
sb.WriteString(specialStyle.Render("▁"))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(dangerStyle.Render("█"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) groupSparkline(groupID int, width int) string {
|
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
var childStatuses [][]bool
|
|
||||||
for _, s := range allSites {
|
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
|
||||||
if len(hist.Statuses) > 0 {
|
|
||||||
childStatuses = append(childStatuses, hist.Statuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(childStatuses) == 0 {
|
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
|
||||||
}
|
|
||||||
|
|
||||||
maxLen := 0
|
|
||||||
for _, s := range childStatuses {
|
|
||||||
if len(s) > maxLen {
|
|
||||||
maxLen = len(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxLen > width {
|
|
||||||
maxLen = width
|
|
||||||
}
|
|
||||||
|
|
||||||
aggregated := make([]bool, maxLen)
|
|
||||||
for i := 0; i < maxLen; i++ {
|
|
||||||
allUp := true
|
|
||||||
for _, statuses := range childStatuses {
|
|
||||||
idx := len(statuses) - maxLen + i
|
|
||||||
if idx >= 0 && !statuses[idx] {
|
|
||||||
allUp = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aggregated[i] = allUp
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
if remaining := width - len(aggregated); remaining > 0 {
|
|
||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
|
||||||
}
|
|
||||||
for _, up := range aggregated {
|
|
||||||
if up {
|
|
||||||
sb.WriteString(specialStyle.Render("●"))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(dangerStyle.Render("●"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) groupUptime(groupID int) string {
|
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
var allStatuses [][]bool
|
|
||||||
for _, s := range allSites {
|
|
||||||
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
|
|
||||||
hist, _ := m.engine.GetHistory(s.ID)
|
|
||||||
if len(hist.Statuses) > 0 {
|
|
||||||
allStatuses = append(allStatuses, hist.Statuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(allStatuses) == 0 {
|
|
||||||
return subtleStyle.Render("—")
|
|
||||||
}
|
|
||||||
total, up := 0, 0
|
|
||||||
for _, statuses := range allStatuses {
|
|
||||||
for _, s := range statuses {
|
|
||||||
total++
|
|
||||||
if s {
|
|
||||||
up++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmtUptime(func() []bool {
|
|
||||||
out := make([]bool, total)
|
|
||||||
idx := 0
|
|
||||||
for _, statuses := range allStatuses {
|
|
||||||
copy(out[idx:], statuses)
|
|
||||||
idx += len(statuses)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtLatency(d time.Duration) string {
|
|
||||||
ms := d.Milliseconds()
|
|
||||||
if ms == 0 {
|
|
||||||
return subtleStyle.Render("—")
|
|
||||||
}
|
|
||||||
var s string
|
|
||||||
if ms < 1000 {
|
|
||||||
s = fmt.Sprintf("%dms", ms)
|
|
||||||
} else {
|
|
||||||
s = fmt.Sprintf("%.1fs", float64(ms)/1000)
|
|
||||||
}
|
|
||||||
if ms < 200 {
|
|
||||||
return specialStyle.Render(s)
|
|
||||||
}
|
|
||||||
if ms < 500 {
|
|
||||||
return warnStyle.Render(s)
|
|
||||||
}
|
|
||||||
return dangerStyle.Render(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtUptime(statuses []bool) string {
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
return subtleStyle.Render("—")
|
|
||||||
}
|
|
||||||
up := 0
|
|
||||||
for _, s := range statuses {
|
|
||||||
if s {
|
|
||||||
up++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pct := float64(up) / float64(len(statuses)) * 100
|
|
||||||
s := fmt.Sprintf("%.1f%%", pct)
|
|
||||||
if pct >= 99 {
|
|
||||||
return specialStyle.Render(s)
|
|
||||||
}
|
|
||||||
if pct >= 95 {
|
|
||||||
return warnStyle.Render(s)
|
|
||||||
}
|
|
||||||
return dangerStyle.Render(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtSSL(site models.Site) string {
|
|
||||||
if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
|
|
||||||
return subtleStyle.Render("-")
|
|
||||||
}
|
|
||||||
days := int(time.Until(site.CertExpiry).Hours() / 24)
|
|
||||||
s := fmt.Sprintf("%dd", days)
|
|
||||||
if days <= 0 {
|
|
||||||
return dangerStyle.Render("EXPIRED")
|
|
||||||
}
|
|
||||||
if days <= site.ExpiryThreshold {
|
|
||||||
return warnStyle.Render(s)
|
|
||||||
}
|
|
||||||
return specialStyle.Render(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtRetries(site models.Site) string {
|
|
||||||
retriesDone := site.FailureCount - 1
|
|
||||||
if retriesDone < 0 {
|
|
||||||
retriesDone = 0
|
|
||||||
}
|
|
||||||
dispCount := retriesDone
|
|
||||||
if dispCount > site.MaxRetries {
|
|
||||||
dispCount = site.MaxRetries
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
|
|
||||||
if site.Status == "DOWN" {
|
|
||||||
return dangerStyle.Render(s)
|
|
||||||
}
|
|
||||||
if site.Status == "UP" && site.FailureCount > 0 {
|
|
||||||
return warnStyle.Render(s)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtStatus(status string, paused bool, inMaint bool) string {
|
|
||||||
if paused {
|
|
||||||
return warnStyle.Render("PAUSED")
|
|
||||||
}
|
|
||||||
if inMaint {
|
|
||||||
return maintStyle.Render("MAINT")
|
|
||||||
}
|
|
||||||
switch status {
|
|
||||||
case "DOWN", "SSL EXP":
|
|
||||||
return dangerStyle.Render(status)
|
|
||||||
case "LATE":
|
|
||||||
return warnStyle.Render(status)
|
|
||||||
case "PENDING":
|
|
||||||
return subtleStyle.Render(status)
|
|
||||||
default:
|
|
||||||
return specialStyle.Render(status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtDuration(d time.Duration) string {
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
h := int(d.Hours())
|
|
||||||
m := int(d.Minutes()) % 60
|
|
||||||
if m > 0 {
|
|
||||||
return fmt.Sprintf("%dh %dm", h, m)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dh", h)
|
|
||||||
}
|
|
||||||
days := int(d.Hours()) / 24
|
|
||||||
hours := int(d.Hours()) % 24
|
|
||||||
if hours > 0 {
|
|
||||||
return fmt.Sprintf("%dd %dh", days, hours)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd", days)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tableLayout struct {
|
type tableLayout struct {
|
||||||
nameW, sparkW int
|
nameW, sparkW int
|
||||||
headers []string
|
headers []string
|
||||||
@@ -357,12 +47,10 @@ func (m Model) computeLayout() tableLayout {
|
|||||||
var widths []int
|
var widths []int
|
||||||
|
|
||||||
if wide {
|
if wide {
|
||||||
// # NAME TYPE STATUS LATENCY UPTIME HISTORY SSL RETRIES
|
|
||||||
headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"}
|
headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"}
|
||||||
widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9}
|
widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9}
|
||||||
fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9
|
fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9
|
||||||
} else {
|
} else {
|
||||||
// # NAME TYPE STATUS LAT UP% HISTORY SSL RT
|
|
||||||
headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"}
|
headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"}
|
||||||
widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5}
|
widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5}
|
||||||
fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5
|
fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5
|
||||||
@@ -792,202 +480,3 @@ func (m *Model) submitSiteForm() {
|
|||||||
}
|
}
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewDetailPanel() string {
|
|
||||||
if m.cursor >= len(m.sites) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
site := m.sites[m.cursor]
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
var breadcrumb string
|
|
||||||
if site.ParentID > 0 {
|
|
||||||
for _, s := range m.sites {
|
|
||||||
if s.ID == site.ParentID {
|
|
||||||
breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if breadcrumb == "" {
|
|
||||||
breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
|
|
||||||
}
|
|
||||||
b.WriteString(breadcrumb + "\n\n")
|
|
||||||
|
|
||||||
row := func(label, value string) {
|
|
||||||
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
section := func(label string) {
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
|
||||||
|
|
||||||
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
|
|
||||||
row("Error", dangerStyle.Render(limitStr(site.LastError, 60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "http" && site.StatusCode > 0 {
|
|
||||||
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
|
||||||
dur := time.Since(site.StatusChangedAt)
|
|
||||||
row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.LastSuccessAt.IsZero() {
|
|
||||||
ago := time.Since(site.LastSuccessAt)
|
|
||||||
row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.isMonitorInMaintenance(site.ID) {
|
|
||||||
for _, mw := range m.maintenanceWindows {
|
|
||||||
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
|
||||||
row("Maintenance", maintStyle.Render(mw.Title))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
section("ENDPOINT")
|
|
||||||
row("Type", site.Type)
|
|
||||||
if site.URL != "" {
|
|
||||||
row("URL", site.URL)
|
|
||||||
}
|
|
||||||
if site.Hostname != "" {
|
|
||||||
row("Host", site.Hostname)
|
|
||||||
}
|
|
||||||
if site.Port > 0 {
|
|
||||||
row("Port", strconv.Itoa(site.Port))
|
|
||||||
}
|
|
||||||
|
|
||||||
section("TIMING")
|
|
||||||
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
|
||||||
if site.Timeout > 0 {
|
|
||||||
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
|
||||||
}
|
|
||||||
row("Latency", fmtLatency(site.Latency))
|
|
||||||
row("Uptime", fmtUptime(hist.Statuses))
|
|
||||||
if !site.LastCheck.IsZero() {
|
|
||||||
row("Last Check", site.LastCheck.Format("15:04:05"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "http" {
|
|
||||||
section("HTTP")
|
|
||||||
if site.Method != "" && site.Method != "GET" {
|
|
||||||
row("Method", site.Method)
|
|
||||||
}
|
|
||||||
codes := site.AcceptedCodes
|
|
||||||
if codes == "" {
|
|
||||||
codes = "200-299"
|
|
||||||
}
|
|
||||||
row("Codes", codes)
|
|
||||||
row("SSL", fmtSSL(site))
|
|
||||||
if site.IgnoreTLS {
|
|
||||||
row("TLS Verify", dangerStyle.Render("disabled"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
|
||||||
section("CONFIG")
|
|
||||||
if site.MaxRetries > 0 {
|
|
||||||
row("Retries", fmtRetries(site))
|
|
||||||
}
|
|
||||||
if site.Regions != "" {
|
|
||||||
row("Regions", site.Regions)
|
|
||||||
}
|
|
||||||
if site.Description != "" {
|
|
||||||
row("Description", site.Description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
probeResults := m.engine.GetProbeResults(site.ID)
|
|
||||||
if len(probeResults) > 0 {
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n")
|
|
||||||
for nodeID, result := range probeResults {
|
|
||||||
status := specialStyle.Render("UP")
|
|
||||||
if !result.IsUp {
|
|
||||||
status = dangerStyle.Render("DN")
|
|
||||||
}
|
|
||||||
latency := time.Duration(result.LatencyNs).Milliseconds()
|
|
||||||
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
|
||||||
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
|
|
||||||
if !result.IsUp && result.ErrorReason != "" {
|
|
||||||
line += " " + dangerStyle.Render(limitStr(result.ErrorReason, 30))
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChanges := m.engine.GetStateChanges(site.ID, 5)
|
|
||||||
if len(stateChanges) > 0 {
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
|
||||||
for _, sc := range stateChanges {
|
|
||||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
|
||||||
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
|
||||||
if sc.ToStatus == "UP" {
|
|
||||||
arrow += specialStyle.Render(sc.ToStatus)
|
|
||||||
} else {
|
|
||||||
arrow += dangerStyle.Render(sc.ToStatus)
|
|
||||||
}
|
|
||||||
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
|
||||||
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
|
||||||
line += " " + dangerStyle.Render(limitStr(sc.ErrorReason, 40))
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
const sparkWidth = 40
|
|
||||||
if site.Type == "push" {
|
|
||||||
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
|
|
||||||
if len(hist.Statuses) > 0 {
|
|
||||||
up := 0
|
|
||||||
for _, s := range hist.Statuses {
|
|
||||||
if s {
|
|
||||||
up++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "\n %s %d/%d checks up",
|
|
||||||
subtleStyle.Render("Heartbeats"),
|
|
||||||
up, len(hist.Statuses))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
|
||||||
// Stats over successful checks only — a failed check is stored as 0ns latency
|
|
||||||
// and would otherwise drag Min to 0ms and skew the average.
|
|
||||||
var minL, maxL, total time.Duration
|
|
||||||
count := 0
|
|
||||||
for i, l := range hist.Latencies {
|
|
||||||
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
minL, maxL = l, l
|
|
||||||
} else if l < minL {
|
|
||||||
minL = l
|
|
||||||
} else if l > maxL {
|
|
||||||
maxL = l
|
|
||||||
}
|
|
||||||
total += l
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
avg := total / time.Duration(count)
|
|
||||||
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
|
||||||
subtleStyle.Render("Min"), minL.Milliseconds(),
|
|
||||||
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
|
||||||
subtleStyle.Render("Max"), maxL.Milliseconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit"))
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
@@ -164,869 +159,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCollapsed(s store.Store) map[int]bool {
|
|
||||||
m := make(map[int]bool)
|
|
||||||
raw, err := s.GetPreference("collapsed_groups")
|
|
||||||
if err != nil || raw == "" {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
var ids []int
|
|
||||||
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
m[id] = true
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveCollapsed(s store.Store, collapsed map[int]bool) {
|
|
||||||
var ids []int
|
|
||||||
for id, v := range collapsed {
|
|
||||||
if v {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(ids)
|
|
||||||
_ = s.SetPreference("collapsed_groups", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
|
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmd tea.Cmd
|
|
||||||
|
|
||||||
if m.state == stateConfirmDelete {
|
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
||||||
switch keyMsg.String() {
|
|
||||||
case "y", "Y":
|
|
||||||
switch m.deleteTab {
|
|
||||||
case 0:
|
|
||||||
if err := m.store.DeleteSite(m.deleteID); err != nil {
|
|
||||||
m.engine.AddLog("Delete site failed: " + err.Error())
|
|
||||||
}
|
|
||||||
m.engine.RemoveSite(m.deleteID)
|
|
||||||
m.adjustCursor(len(m.sites) - 1)
|
|
||||||
case 1:
|
|
||||||
if err := m.store.DeleteAlert(m.deleteID); err != nil {
|
|
||||||
m.engine.AddLog("Delete alert failed: " + err.Error())
|
|
||||||
}
|
|
||||||
m.adjustCursor(len(m.alerts) - 1)
|
|
||||||
case 4:
|
|
||||||
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
|
||||||
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
|
||||||
}
|
|
||||||
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
|
||||||
case 5:
|
|
||||||
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
|
||||||
m.engine.AddLog("Delete user failed: " + err.Error())
|
|
||||||
}
|
|
||||||
m.adjustCursor(len(m.users) - 1)
|
|
||||||
}
|
|
||||||
m.refreshData()
|
|
||||||
m.state = stateDashboard
|
|
||||||
if m.deleteTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
case "n", "N", "esc":
|
|
||||||
m.state = stateDashboard
|
|
||||||
if m.deleteTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
|
||||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
|
||||||
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
|
||||||
m.termWidth = wsm.Width
|
|
||||||
m.termHeight = wsm.Height
|
|
||||||
}
|
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
|
||||||
if keyMsg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if keyMsg.String() == "esc" {
|
|
||||||
m.huhForm = nil
|
|
||||||
m.state = stateDashboard
|
|
||||||
if m.currentTab == 5 {
|
|
||||||
m.state = stateUsers
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.huhForm != nil {
|
|
||||||
form, formCmd := m.huhForm.Update(msg)
|
|
||||||
if f, ok := form.(*huh.Form); ok {
|
|
||||||
m.huhForm = f
|
|
||||||
}
|
|
||||||
if m.huhForm.State == huh.StateCompleted {
|
|
||||||
m.submitForm()
|
|
||||||
m.refreshData()
|
|
||||||
m.huhForm = nil
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
return m, formCmd
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.termWidth = msg.Width
|
|
||||||
m.termHeight = msg.Height
|
|
||||||
chrome := chromeBase
|
|
||||||
if m.filterMode || m.filterText != "" {
|
|
||||||
chrome++
|
|
||||||
}
|
|
||||||
m.maxTableRows = msg.Height - chrome
|
|
||||||
if m.maxTableRows < 1 {
|
|
||||||
m.maxTableRows = 1
|
|
||||||
}
|
|
||||||
m.logViewport.Width = msg.Width - chromePadH
|
|
||||||
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
|
||||||
return m, tea.ClearScreen
|
|
||||||
|
|
||||||
case time.Time:
|
|
||||||
m.refreshData()
|
|
||||||
m.tickCount++
|
|
||||||
target := math.Sin(float64(m.tickCount)*0.3)*0.5 + 0.5
|
|
||||||
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
|
|
||||||
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
|
|
||||||
|
|
||||||
case tea.MouseMsg:
|
|
||||||
if m.state == stateDashboard || m.state == stateLogs || m.state == stateUsers {
|
|
||||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
|
||||||
return m.handleClick(msg)
|
|
||||||
}
|
|
||||||
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
|
|
||||||
if m.state == stateLogs {
|
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
|
||||||
m.logViewport.ScrollUp(3)
|
|
||||||
} else {
|
|
||||||
m.logViewport.ScrollDown(3)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
listLen := len(m.sites)
|
|
||||||
switch m.currentTab {
|
|
||||||
case 1:
|
|
||||||
listLen = len(m.alerts)
|
|
||||||
case 3:
|
|
||||||
listLen = len(m.nodes)
|
|
||||||
case 4:
|
|
||||||
listLen = len(m.maintenanceWindows)
|
|
||||||
case 5:
|
|
||||||
listLen = len(m.users)
|
|
||||||
}
|
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
|
||||||
if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
if m.cursor < m.tableOffset {
|
|
||||||
m.tableOffset = m.cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if m.cursor < listLen-1 {
|
|
||||||
m.cursor++
|
|
||||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
|
||||||
m.tableOffset++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.KeyMsg:
|
|
||||||
if msg.String() == "ctrl+c" {
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
if msg.String() == "ctrl+l" {
|
|
||||||
return m, tea.ClearScreen
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.filterMode {
|
|
||||||
switch msg.String() {
|
|
||||||
case "esc":
|
|
||||||
m.filterMode = false
|
|
||||||
m.filterText = ""
|
|
||||||
m.cursor = 0
|
|
||||||
m.tableOffset = 0
|
|
||||||
m.refreshData()
|
|
||||||
case "enter":
|
|
||||||
m.filterMode = false
|
|
||||||
case "backspace":
|
|
||||||
if len(m.filterText) > 0 {
|
|
||||||
m.filterText = m.filterText[:len(m.filterText)-1]
|
|
||||||
m.cursor = 0
|
|
||||||
m.tableOffset = 0
|
|
||||||
m.refreshData()
|
|
||||||
}
|
|
||||||
case "ctrl+c":
|
|
||||||
return m, tea.Quit
|
|
||||||
default:
|
|
||||||
if len(msg.String()) == 1 {
|
|
||||||
m.filterText += msg.String()
|
|
||||||
m.cursor = 0
|
|
||||||
m.tableOffset = 0
|
|
||||||
m.refreshData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch m.state {
|
|
||||||
case stateDetail:
|
|
||||||
switch msg.String() {
|
|
||||||
case "i", "esc":
|
|
||||||
m.state = stateDashboard
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case stateAlertDetail:
|
|
||||||
switch msg.String() {
|
|
||||||
case "i", "esc":
|
|
||||||
m.state = stateDashboard
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case stateDashboard, stateLogs, stateUsers:
|
|
||||||
switch msg.String() {
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "/":
|
|
||||||
if m.currentTab == 0 {
|
|
||||||
m.filterMode = true
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "f":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logFilterImportant = !m.logFilterImportant
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "tab":
|
|
||||||
m.switchTab(m.currentTab + 1)
|
|
||||||
case "pgup", "pgdown":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
case "up", "k":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logViewport.ScrollUp(1)
|
|
||||||
} else if m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
if m.cursor < m.tableOffset {
|
|
||||||
m.tableOffset = m.cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "down", "j":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logViewport.ScrollDown(1)
|
|
||||||
} else {
|
|
||||||
max := len(m.sites) - 1
|
|
||||||
if m.currentTab == 1 {
|
|
||||||
max = len(m.alerts) - 1
|
|
||||||
}
|
|
||||||
if m.currentTab == 3 {
|
|
||||||
max = len(m.nodes) - 1
|
|
||||||
}
|
|
||||||
if m.currentTab == 4 {
|
|
||||||
max = len(m.maintenanceWindows) - 1
|
|
||||||
}
|
|
||||||
if m.currentTab == 5 {
|
|
||||||
max = len(m.users) - 1
|
|
||||||
}
|
|
||||||
if m.cursor < max {
|
|
||||||
m.cursor++
|
|
||||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
|
||||||
m.tableOffset++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "n":
|
|
||||||
m.editID = 0
|
|
||||||
m.editToken = ""
|
|
||||||
if m.currentTab == 0 {
|
|
||||||
m.state = stateFormSite
|
|
||||||
return m, m.initSiteHuhForm()
|
|
||||||
} else if m.currentTab == 1 {
|
|
||||||
m.state = stateFormAlert
|
|
||||||
return m, m.initAlertHuhForm()
|
|
||||||
} else if m.currentTab == 4 {
|
|
||||||
m.state = stateFormMaint
|
|
||||||
return m, m.initMaintHuhForm()
|
|
||||||
} else if m.currentTab == 5 && m.isAdmin {
|
|
||||||
m.state = stateFormUser
|
|
||||||
return m, m.initUserHuhForm()
|
|
||||||
}
|
|
||||||
case "e", "enter":
|
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
||||||
m.editID = m.sites[m.cursor].ID
|
|
||||||
m.editToken = m.sites[m.cursor].Token
|
|
||||||
m.state = stateFormSite
|
|
||||||
return m, m.initSiteHuhForm()
|
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
m.editID = m.alerts[m.cursor].ID
|
|
||||||
m.state = stateFormAlert
|
|
||||||
return m, m.initAlertHuhForm()
|
|
||||||
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
|
||||||
m.editID = m.users[m.cursor].ID
|
|
||||||
m.state = stateFormUser
|
|
||||||
return m, m.initUserHuhForm()
|
|
||||||
}
|
|
||||||
case "t":
|
|
||||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
a := m.alerts[m.cursor]
|
|
||||||
go func() {
|
|
||||||
if err := m.engine.TestAlert(a.ID); err != nil {
|
|
||||||
m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case " ":
|
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
|
||||||
gid := m.sites[m.cursor].ID
|
|
||||||
m.collapsed[gid] = !m.collapsed[gid]
|
|
||||||
saveCollapsed(m.store, m.collapsed)
|
|
||||||
m.refreshData()
|
|
||||||
}
|
|
||||||
case "p":
|
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
||||||
site := m.sites[m.cursor]
|
|
||||||
m.engine.ToggleSitePause(site.ID)
|
|
||||||
site.Paused = !site.Paused
|
|
||||||
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
|
|
||||||
m.refreshData()
|
|
||||||
}
|
|
||||||
case "i":
|
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
||||||
m.state = stateDetail
|
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
m.state = stateAlertDetail
|
|
||||||
}
|
|
||||||
case "x":
|
|
||||||
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
|
||||||
mw := m.maintenanceWindows[m.cursor]
|
|
||||||
now := time.Now()
|
|
||||||
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
|
||||||
if isActive {
|
|
||||||
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
|
||||||
m.engine.AddLog("End maintenance failed: " + err.Error())
|
|
||||||
}
|
|
||||||
m.refreshData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "T":
|
|
||||||
m.themeIndex = (m.themeIndex + 1) % len(themes)
|
|
||||||
m.theme = themes[m.themeIndex]
|
|
||||||
applyTheme(m.theme)
|
|
||||||
_ = m.store.SetPreference("theme", m.theme.Name)
|
|
||||||
case "d", "backspace":
|
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
|
||||||
m.deleteName = m.sites[m.cursor].Name
|
|
||||||
m.deleteTab = 0
|
|
||||||
m.state = stateConfirmDelete
|
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
m.deleteID = m.alerts[m.cursor].ID
|
|
||||||
m.deleteName = m.alerts[m.cursor].Name
|
|
||||||
m.deleteTab = 1
|
|
||||||
m.state = stateConfirmDelete
|
|
||||||
} else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
|
||||||
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
|
||||||
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
|
||||||
m.deleteTab = 4
|
|
||||||
m.state = stateConfirmDelete
|
|
||||||
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
|
||||||
m.deleteID = m.users[m.cursor].ID
|
|
||||||
m.deleteName = m.users[m.cursor].Username
|
|
||||||
m.deleteTab = 5
|
|
||||||
m.state = stateConfirmDelete
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|
||||||
tabCount := 5
|
|
||||||
if m.isAdmin {
|
|
||||||
tabCount = 6
|
|
||||||
}
|
|
||||||
for i := 0; i < tabCount; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
|
||||||
m.switchTab(i)
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.currentTab == 0 {
|
|
||||||
end := m.tableOffset + m.maxTableRows
|
|
||||||
if end > len(m.sites) {
|
|
||||||
end = len(m.sites)
|
|
||||||
}
|
|
||||||
for i := m.tableOffset; i < end; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) {
|
|
||||||
m.cursor = i
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.currentTab == 1 {
|
|
||||||
end := m.tableOffset + m.maxTableRows
|
|
||||||
if end > len(m.alerts) {
|
|
||||||
end = len(m.alerts)
|
|
||||||
}
|
|
||||||
for i := m.tableOffset; i < end; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("alert-%d", i)).InBounds(msg) {
|
|
||||||
m.cursor = i
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.currentTab == 4 {
|
|
||||||
end := m.tableOffset + m.maxTableRows
|
|
||||||
if end > len(m.maintenanceWindows) {
|
|
||||||
end = len(m.maintenanceWindows)
|
|
||||||
}
|
|
||||||
for i := m.tableOffset; i < end; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
|
|
||||||
m.cursor = i
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.currentTab == 5 {
|
|
||||||
end := m.tableOffset + m.maxTableRows
|
|
||||||
if end > len(m.users) {
|
|
||||||
end = len(m.users)
|
|
||||||
}
|
|
||||||
for i := m.tableOffset; i < end; i++ {
|
|
||||||
if m.zones.Get(fmt.Sprintf("user-%d", i)).InBounds(msg) {
|
|
||||||
m.cursor = i
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) switchTab(idx int) {
|
|
||||||
maxTabs := 4
|
|
||||||
if m.isAdmin {
|
|
||||||
maxTabs = 5
|
|
||||||
}
|
|
||||||
if idx > maxTabs {
|
|
||||||
idx = 0
|
|
||||||
}
|
|
||||||
m.currentTab = idx
|
|
||||||
m.cursor = 0
|
|
||||||
m.tableOffset = 0
|
|
||||||
switch idx {
|
|
||||||
case 2:
|
|
||||||
m.state = stateLogs
|
|
||||||
case 5:
|
|
||||||
m.state = stateUsers
|
|
||||||
default:
|
|
||||||
m.state = stateDashboard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) adjustCursor(newLen int) {
|
|
||||||
if m.cursor >= newLen && m.cursor > 0 {
|
|
||||||
m.cursor--
|
|
||||||
}
|
|
||||||
if m.cursor < m.tableOffset {
|
|
||||||
m.tableOffset = m.cursor
|
|
||||||
if m.tableOffset < 0 {
|
|
||||||
m.tableOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) refreshData() {
|
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
|
|
||||||
var groups, ungrouped []models.Site
|
|
||||||
children := make(map[int][]models.Site)
|
|
||||||
for _, s := range allSites {
|
|
||||||
if s.Type == "group" {
|
|
||||||
groups = append(groups, s)
|
|
||||||
} else if s.ParentID > 0 {
|
|
||||||
children[s.ParentID] = append(children[s.ParentID], s)
|
|
||||||
} else {
|
|
||||||
ungrouped = append(ungrouped, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
|
||||||
for pid := range children {
|
|
||||||
c := children[pid]
|
|
||||||
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
|
||||||
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
|
|
||||||
children[pid] = c
|
|
||||||
}
|
|
||||||
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
|
||||||
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
|
|
||||||
|
|
||||||
var ordered []models.Site
|
|
||||||
for _, g := range groups {
|
|
||||||
ordered = append(ordered, g)
|
|
||||||
if !m.collapsed[g.ID] {
|
|
||||||
ordered = append(ordered, children[g.ID]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ordered = append(ordered, ungrouped...)
|
|
||||||
if m.filterText != "" {
|
|
||||||
var filtered []models.Site
|
|
||||||
needle := strings.ToLower(m.filterText)
|
|
||||||
for _, s := range ordered {
|
|
||||||
if strings.Contains(strings.ToLower(s.Name), needle) {
|
|
||||||
filtered = append(filtered, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ordered = filtered
|
|
||||||
}
|
|
||||||
m.sites = ordered
|
|
||||||
if alerts, err := m.store.GetAllAlerts(); err == nil {
|
|
||||||
m.alerts = alerts
|
|
||||||
}
|
|
||||||
if m.isAdmin {
|
|
||||||
if users, err := m.store.GetAllUsers(); err == nil {
|
|
||||||
m.users = users
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nodes, err := m.store.GetAllNodes(); err == nil {
|
|
||||||
m.nodes = nodes
|
|
||||||
}
|
|
||||||
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
|
||||||
m.maintenanceWindows = windows
|
|
||||||
}
|
|
||||||
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
|
||||||
|
|
||||||
listLen := len(m.sites)
|
|
||||||
switch m.currentTab {
|
|
||||||
case 1:
|
|
||||||
listLen = len(m.alerts)
|
|
||||||
case 3:
|
|
||||||
listLen = len(m.nodes)
|
|
||||||
case 4:
|
|
||||||
listLen = len(m.maintenanceWindows)
|
|
||||||
case 5:
|
|
||||||
listLen = len(m.users)
|
|
||||||
}
|
|
||||||
if listLen > 0 && m.cursor >= listLen {
|
|
||||||
m.cursor = listLen - 1
|
|
||||||
}
|
|
||||||
if m.cursor < m.tableOffset {
|
|
||||||
m.tableOffset = m.cursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) submitForm() {
|
|
||||||
switch m.state {
|
|
||||||
case stateFormSite:
|
|
||||||
if m.siteFormData != nil {
|
|
||||||
m.submitSiteForm()
|
|
||||||
}
|
|
||||||
case stateFormAlert:
|
|
||||||
if m.alertFormData != nil {
|
|
||||||
m.submitAlertForm()
|
|
||||||
}
|
|
||||||
case stateFormUser:
|
|
||||||
if m.userFormData != nil {
|
|
||||||
m.submitUserForm()
|
|
||||||
}
|
|
||||||
case stateFormMaint:
|
|
||||||
if m.maintFormData != nil {
|
|
||||||
m.submitMaintForm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) pulseIndicator() string {
|
|
||||||
hasDown := false
|
|
||||||
for _, s := range m.sites {
|
|
||||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
|
||||||
hasDown = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Stills can't show animation: render a stable status dot in demo mode.
|
|
||||||
if m.demoMode {
|
|
||||||
c := m.theme.Success
|
|
||||||
if hasDown {
|
|
||||||
c = m.theme.Danger
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle().Foreground(c).Render("●")
|
|
||||||
}
|
|
||||||
frame := m.tickCount % len(pulseFrames)
|
|
||||||
brightness := int(m.pulsePos*155) + 100
|
|
||||||
if brightness > 255 {
|
|
||||||
brightness = 255
|
|
||||||
}
|
|
||||||
var color string
|
|
||||||
if hasDown {
|
|
||||||
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
|
||||||
} else {
|
|
||||||
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
|
||||||
}
|
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
switch m.state {
|
|
||||||
case stateConfirmDelete:
|
|
||||||
kind := "monitor"
|
|
||||||
switch m.deleteTab {
|
|
||||||
case 1:
|
|
||||||
kind = "alert"
|
|
||||||
case 4:
|
|
||||||
kind = "maintenance window"
|
|
||||||
case 5:
|
|
||||||
kind = "user"
|
|
||||||
}
|
|
||||||
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
|
||||||
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
|
||||||
box := lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.RoundedBorder()).
|
|
||||||
BorderForeground(m.theme.Danger).
|
|
||||||
Padding(1, 3).
|
|
||||||
Render(msg + "\n\n" + hint)
|
|
||||||
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
|
||||||
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
|
||||||
if m.huhForm != nil {
|
|
||||||
title := ""
|
|
||||||
switch m.state {
|
|
||||||
case stateFormSite:
|
|
||||||
title = "Add Monitor"
|
|
||||||
if m.editID > 0 {
|
|
||||||
title = fmt.Sprintf("Edit Monitor #%d", m.editID)
|
|
||||||
}
|
|
||||||
case stateFormAlert:
|
|
||||||
title = "Add Alert"
|
|
||||||
if m.editID > 0 {
|
|
||||||
title = fmt.Sprintf("Edit Alert #%d", m.editID)
|
|
||||||
}
|
|
||||||
case stateFormUser:
|
|
||||||
title = "Add User"
|
|
||||||
if m.editID > 0 {
|
|
||||||
title = fmt.Sprintf("Edit User #%d", m.editID)
|
|
||||||
}
|
|
||||||
case stateFormMaint:
|
|
||||||
title = "New Maintenance Window"
|
|
||||||
}
|
|
||||||
formHeight := m.termHeight - 7
|
|
||||||
if formHeight < 5 {
|
|
||||||
formHeight = 5
|
|
||||||
}
|
|
||||||
m.huhForm.WithHeight(formHeight)
|
|
||||||
header := titleStyle.Render(title)
|
|
||||||
footer := subtleStyle.Render("\n[Esc] Cancel")
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
case stateDetail:
|
|
||||||
return m.viewDetailPanel()
|
|
||||||
case stateAlertDetail:
|
|
||||||
return m.viewAlertDetailPanel()
|
|
||||||
default:
|
|
||||||
return m.zones.Scan(m.viewDashboard())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewDashboard() string {
|
|
||||||
allSites := m.engine.GetAllSites()
|
|
||||||
totalMonitors := 0
|
|
||||||
downCount := 0
|
|
||||||
lateCount := 0
|
|
||||||
for _, s := range allSites {
|
|
||||||
if s.Type == "group" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalMonitors++
|
|
||||||
if s.Paused || m.isMonitorInMaintenance(s.ID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch s.Status {
|
|
||||||
case "DOWN", "SSL EXP":
|
|
||||||
downCount++
|
|
||||||
case "LATE":
|
|
||||||
lateCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
offlineNodes := 0
|
|
||||||
for _, n := range m.nodes {
|
|
||||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
|
|
||||||
offlineNodes++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var sitesLabel string
|
|
||||||
if downCount > 0 {
|
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
|
|
||||||
} else if lateCount > 0 {
|
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d⚠)", lateCount)
|
|
||||||
} else if totalMonitors > 0 {
|
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
|
|
||||||
} else {
|
|
||||||
sitesLabel = "Sites"
|
|
||||||
}
|
|
||||||
var nodesLabel string
|
|
||||||
if offlineNodes > 0 {
|
|
||||||
nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes)
|
|
||||||
} else if len(m.nodes) > 0 {
|
|
||||||
nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes))
|
|
||||||
} else {
|
|
||||||
nodesLabel = "Nodes"
|
|
||||||
}
|
|
||||||
|
|
||||||
activeMaint := 0
|
|
||||||
for _, mw := range m.maintenanceWindows {
|
|
||||||
now := time.Now()
|
|
||||||
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
|
||||||
activeMaint++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var maintLabel string
|
|
||||||
if activeMaint > 0 {
|
|
||||||
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
|
|
||||||
} else {
|
|
||||||
maintLabel = "Maint"
|
|
||||||
}
|
|
||||||
|
|
||||||
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
|
||||||
if m.isAdmin {
|
|
||||||
tabs = append(tabs, "Users")
|
|
||||||
}
|
|
||||||
var renderedTabs []string
|
|
||||||
for i, t := range tabs {
|
|
||||||
var rendered string
|
|
||||||
if i == m.currentTab {
|
|
||||||
rendered = activeTab.Render(t)
|
|
||||||
} else {
|
|
||||||
rendered = inactiveTab.Render(t)
|
|
||||||
}
|
|
||||||
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
|
||||||
}
|
|
||||||
header := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
|
||||||
|
|
||||||
pulse := m.pulseIndicator()
|
|
||||||
header = pulse + " " + header
|
|
||||||
|
|
||||||
var content string
|
|
||||||
switch m.currentTab {
|
|
||||||
case 0:
|
|
||||||
content = m.viewSitesTab()
|
|
||||||
case 1:
|
|
||||||
content = m.viewAlertsTab()
|
|
||||||
case 2:
|
|
||||||
content = m.viewLogsTab()
|
|
||||||
case 3:
|
|
||||||
content = m.viewNodesTab()
|
|
||||||
case 4:
|
|
||||||
content = m.viewMaintTab()
|
|
||||||
case 5:
|
|
||||||
if m.isAdmin {
|
|
||||||
content = m.viewUsersTab()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
upCount := totalMonitors - downCount - lateCount
|
|
||||||
var upStr string
|
|
||||||
if downCount > 0 {
|
|
||||||
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
||||||
} else if lateCount > 0 {
|
|
||||||
upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
||||||
} else {
|
|
||||||
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
||||||
}
|
|
||||||
statusParts := []string{upStr}
|
|
||||||
if lateCount > 0 {
|
|
||||||
statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", lateCount)))
|
|
||||||
}
|
|
||||||
if len(m.nodes) > 0 {
|
|
||||||
online := 0
|
|
||||||
for _, n := range m.nodes {
|
|
||||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
|
||||||
online++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
probeLabel := "probes"
|
|
||||||
if online == 1 {
|
|
||||||
probeLabel = "probe"
|
|
||||||
}
|
|
||||||
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
|
||||||
}
|
|
||||||
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
|
||||||
|
|
||||||
var footer string
|
|
||||||
if m.filterMode {
|
|
||||||
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
|
||||||
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
|
||||||
} else {
|
|
||||||
var keys string
|
|
||||||
switch m.currentTab {
|
|
||||||
case 0:
|
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 1:
|
|
||||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 2:
|
|
||||||
keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 4:
|
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 5:
|
|
||||||
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
default:
|
|
||||||
keys = "[T]Theme [Tab]Switch [q]Quit"
|
|
||||||
}
|
|
||||||
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
|
|
||||||
if m.filterText != "" && m.currentTab == 0 {
|
|
||||||
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s := lipgloss.NewStyle().Padding(1, 2)
|
|
||||||
if m.termHeight > 0 {
|
|
||||||
s = s.MaxHeight(m.termHeight)
|
|
||||||
}
|
|
||||||
return s.Render(header + "\n" + content + "\n" + footer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func siteOrder(s models.Site) int {
|
|
||||||
if s.Paused {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
switch s.Status {
|
|
||||||
case "DOWN", "SSL EXP":
|
|
||||||
return 0
|
|
||||||
case "LATE":
|
|
||||||
return 1
|
|
||||||
case "PENDING":
|
|
||||||
return 3
|
|
||||||
default:
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func limitStr(text string, max int) string {
|
|
||||||
runes := []rune(text)
|
|
||||||
if len(runes) > max {
|
|
||||||
return string(runes[:max-3]) + "..."
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,539 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.state == stateConfirmDelete {
|
||||||
|
return m.handleConfirmDelete(msg)
|
||||||
|
}
|
||||||
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||||
|
return m.handleFormMsg(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
return m.handleResize(msg)
|
||||||
|
case time.Time:
|
||||||
|
return m.handleTick(msg)
|
||||||
|
case tea.MouseMsg:
|
||||||
|
return m.handleMouse(msg)
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
keyMsg, ok := msg.(tea.KeyMsg)
|
||||||
|
if !ok {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch keyMsg.String() {
|
||||||
|
case "y", "Y":
|
||||||
|
switch m.deleteTab {
|
||||||
|
case 0:
|
||||||
|
if err := m.store.DeleteSite(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete site failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.engine.RemoveSite(m.deleteID)
|
||||||
|
m.adjustCursor(len(m.sites) - 1)
|
||||||
|
case 1:
|
||||||
|
if err := m.store.DeleteAlert(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete alert failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.adjustCursor(len(m.alerts) - 1)
|
||||||
|
case 4:
|
||||||
|
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||||
|
case 5:
|
||||||
|
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete user failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.adjustCursor(len(m.users) - 1)
|
||||||
|
}
|
||||||
|
m.refreshData()
|
||||||
|
m.state = stateDashboard
|
||||||
|
if m.deleteTab == 5 {
|
||||||
|
m.state = stateUsers
|
||||||
|
}
|
||||||
|
case "n", "N", "esc":
|
||||||
|
m.state = stateDashboard
|
||||||
|
if m.deleteTab == 5 {
|
||||||
|
m.state = stateUsers
|
||||||
|
}
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.termWidth = wsm.Width
|
||||||
|
m.termHeight = wsm.Height
|
||||||
|
}
|
||||||
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
if keyMsg.String() == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if keyMsg.String() == "esc" {
|
||||||
|
m.huhForm = nil
|
||||||
|
m.state = stateDashboard
|
||||||
|
if m.currentTab == 5 {
|
||||||
|
m.state = stateUsers
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.huhForm != nil {
|
||||||
|
form, formCmd := m.huhForm.Update(msg)
|
||||||
|
if f, ok := form.(*huh.Form); ok {
|
||||||
|
m.huhForm = f
|
||||||
|
}
|
||||||
|
if m.huhForm.State == huh.StateCompleted {
|
||||||
|
m.submitForm()
|
||||||
|
m.refreshData()
|
||||||
|
m.huhForm = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, formCmd
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.termWidth = msg.Width
|
||||||
|
m.termHeight = msg.Height
|
||||||
|
chrome := chromeBase
|
||||||
|
if m.filterMode || m.filterText != "" {
|
||||||
|
chrome++
|
||||||
|
}
|
||||||
|
m.maxTableRows = msg.Height - chrome
|
||||||
|
if m.maxTableRows < 1 {
|
||||||
|
m.maxTableRows = 1
|
||||||
|
}
|
||||||
|
m.logViewport.Width = msg.Width - chromePadH
|
||||||
|
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
|
||||||
|
m.refreshData()
|
||||||
|
m.tickCount++
|
||||||
|
target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5
|
||||||
|
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
|
||||||
|
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||||
|
return m.handleClick(msg)
|
||||||
|
}
|
||||||
|
if msg.Button != tea.MouseButtonWheelUp && msg.Button != tea.MouseButtonWheelDown {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.state == stateLogs {
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
m.logViewport.ScrollUp(3)
|
||||||
|
} else {
|
||||||
|
m.logViewport.ScrollDown(3)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
listLen := m.currentListLen()
|
||||||
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
if m.cursor < m.tableOffset {
|
||||||
|
m.tableOffset = m.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if m.cursor < listLen-1 {
|
||||||
|
m.cursor++
|
||||||
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||||
|
m.tableOffset++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.String() == "ctrl+c" {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if msg.String() == "ctrl+l" {
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.filterMode {
|
||||||
|
return m.handleFilterKey(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.state {
|
||||||
|
case stateDetail:
|
||||||
|
return m.handleDetailKey(msg)
|
||||||
|
case stateAlertDetail:
|
||||||
|
return m.handleAlertDetailKey(msg)
|
||||||
|
case stateDashboard, stateLogs, stateUsers:
|
||||||
|
return m.handleDashboardKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
m.filterMode = false
|
||||||
|
m.filterText = ""
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
case "enter":
|
||||||
|
m.filterMode = false
|
||||||
|
case "backspace":
|
||||||
|
if len(m.filterText) > 0 {
|
||||||
|
m.filterText = m.filterText[:len(m.filterText)-1]
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
case "ctrl+c":
|
||||||
|
return m, tea.Quit
|
||||||
|
default:
|
||||||
|
if len(msg.String()) == 1 {
|
||||||
|
m.filterText += msg.String()
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "i", "esc":
|
||||||
|
m.state = stateDashboard
|
||||||
|
case "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.String() {
|
||||||
|
case "i", "esc":
|
||||||
|
m.state = stateDashboard
|
||||||
|
case "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
switch msg.String() {
|
||||||
|
case "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
case "/":
|
||||||
|
if m.currentTab == 0 {
|
||||||
|
m.filterMode = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "f":
|
||||||
|
if m.state == stateLogs {
|
||||||
|
m.logFilterImportant = !m.logFilterImportant
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "tab":
|
||||||
|
m.switchTab(m.currentTab + 1)
|
||||||
|
case "pgup", "pgdown":
|
||||||
|
if m.state == stateLogs {
|
||||||
|
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
case "up", "k":
|
||||||
|
if m.state == stateLogs {
|
||||||
|
m.logViewport.ScrollUp(1)
|
||||||
|
} else if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
if m.cursor < m.tableOffset {
|
||||||
|
m.tableOffset = m.cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "down", "j":
|
||||||
|
if m.state == stateLogs {
|
||||||
|
m.logViewport.ScrollDown(1)
|
||||||
|
} else {
|
||||||
|
max := m.currentListLen() - 1
|
||||||
|
if m.cursor < max {
|
||||||
|
m.cursor++
|
||||||
|
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||||
|
m.tableOffset++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "n":
|
||||||
|
return m.handleNewItem()
|
||||||
|
case "e", "enter":
|
||||||
|
return m.handleEditItem()
|
||||||
|
case "t":
|
||||||
|
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||||
|
a := m.alerts[m.cursor]
|
||||||
|
go func() {
|
||||||
|
if err := m.engine.TestAlert(a.ID); err != nil {
|
||||||
|
m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case " ":
|
||||||
|
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||||
|
gid := m.sites[m.cursor].ID
|
||||||
|
m.collapsed[gid] = !m.collapsed[gid]
|
||||||
|
saveCollapsed(m.store, m.collapsed)
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
case "p":
|
||||||
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
m.engine.ToggleSitePause(site.ID)
|
||||||
|
site.Paused = !site.Paused
|
||||||
|
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
case "i":
|
||||||
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
|
m.state = stateDetail
|
||||||
|
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||||
|
m.state = stateAlertDetail
|
||||||
|
}
|
||||||
|
case "x":
|
||||||
|
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
|
mw := m.maintenanceWindows[m.cursor]
|
||||||
|
now := time.Now()
|
||||||
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||||
|
if isActive {
|
||||||
|
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
||||||
|
m.engine.AddLog("End maintenance failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "T":
|
||||||
|
m.themeIndex = (m.themeIndex + 1) % len(themes)
|
||||||
|
m.theme = themes[m.themeIndex]
|
||||||
|
applyTheme(m.theme)
|
||||||
|
_ = m.store.SetPreference("theme", m.theme.Name)
|
||||||
|
case "d", "backspace":
|
||||||
|
return m.handleDeleteItem()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
|
||||||
|
m.editID = 0
|
||||||
|
m.editToken = ""
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
m.state = stateFormSite
|
||||||
|
return m, m.initSiteHuhForm()
|
||||||
|
case 1:
|
||||||
|
m.state = stateFormAlert
|
||||||
|
return m, m.initAlertHuhForm()
|
||||||
|
case 4:
|
||||||
|
m.state = stateFormMaint
|
||||||
|
return m, m.initMaintHuhForm()
|
||||||
|
case 5:
|
||||||
|
if m.isAdmin {
|
||||||
|
m.state = stateFormUser
|
||||||
|
return m, m.initUserHuhForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
if len(m.sites) > 0 {
|
||||||
|
m.editID = m.sites[m.cursor].ID
|
||||||
|
m.editToken = m.sites[m.cursor].Token
|
||||||
|
m.state = stateFormSite
|
||||||
|
return m, m.initSiteHuhForm()
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
if len(m.alerts) > 0 {
|
||||||
|
m.editID = m.alerts[m.cursor].ID
|
||||||
|
m.state = stateFormAlert
|
||||||
|
return m, m.initAlertHuhForm()
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if m.isAdmin && len(m.users) > 0 {
|
||||||
|
m.editID = m.users[m.cursor].ID
|
||||||
|
m.state = stateFormUser
|
||||||
|
return m, m.initUserHuhForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
if len(m.sites) > 0 {
|
||||||
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
|
m.deleteName = m.sites[m.cursor].Name
|
||||||
|
m.deleteTab = 0
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
|
case 1:
|
||||||
|
if len(m.alerts) > 0 {
|
||||||
|
m.deleteID = m.alerts[m.cursor].ID
|
||||||
|
m.deleteName = m.alerts[m.cursor].Name
|
||||||
|
m.deleteTab = 1
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
|
case 4:
|
||||||
|
if len(m.maintenanceWindows) > 0 {
|
||||||
|
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||||
|
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||||
|
m.deleteTab = 4
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
|
case 5:
|
||||||
|
if m.isAdmin && len(m.users) > 0 {
|
||||||
|
m.deleteID = m.users[m.cursor].ID
|
||||||
|
m.deleteName = m.users[m.cursor].Username
|
||||||
|
m.deleteTab = 5
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
tabCount := 5
|
||||||
|
if m.isAdmin {
|
||||||
|
tabCount = 6
|
||||||
|
}
|
||||||
|
for i := 0; i < tabCount; i++ {
|
||||||
|
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||||
|
m.switchTab(i)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, listLen := m.currentZonePrefix()
|
||||||
|
end := m.tableOffset + m.maxTableRows
|
||||||
|
if end > listLen {
|
||||||
|
end = listLen
|
||||||
|
}
|
||||||
|
for i := m.tableOffset; i < end; i++ {
|
||||||
|
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||||
|
m.cursor = i
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) switchTab(idx int) {
|
||||||
|
maxTabs := 4
|
||||||
|
if m.isAdmin {
|
||||||
|
maxTabs = 5
|
||||||
|
}
|
||||||
|
if idx > maxTabs {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
m.currentTab = idx
|
||||||
|
m.cursor = 0
|
||||||
|
m.tableOffset = 0
|
||||||
|
switch idx {
|
||||||
|
case 2:
|
||||||
|
m.state = stateLogs
|
||||||
|
case 5:
|
||||||
|
m.state = stateUsers
|
||||||
|
default:
|
||||||
|
m.state = stateDashboard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) adjustCursor(newLen int) {
|
||||||
|
if m.cursor >= newLen && m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
if m.cursor < m.tableOffset {
|
||||||
|
m.tableOffset = m.cursor
|
||||||
|
if m.tableOffset < 0 {
|
||||||
|
m.tableOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) submitForm() {
|
||||||
|
switch m.state {
|
||||||
|
case stateFormSite:
|
||||||
|
if m.siteFormData != nil {
|
||||||
|
m.submitSiteForm()
|
||||||
|
}
|
||||||
|
case stateFormAlert:
|
||||||
|
if m.alertFormData != nil {
|
||||||
|
m.submitAlertForm()
|
||||||
|
}
|
||||||
|
case stateFormUser:
|
||||||
|
if m.userFormData != nil {
|
||||||
|
m.submitUserForm()
|
||||||
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
if m.maintFormData != nil {
|
||||||
|
m.submitMaintForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentListLen() int {
|
||||||
|
switch m.currentTab {
|
||||||
|
case 1:
|
||||||
|
return len(m.alerts)
|
||||||
|
case 3:
|
||||||
|
return len(m.nodes)
|
||||||
|
case 4:
|
||||||
|
return len(m.maintenanceWindows)
|
||||||
|
case 5:
|
||||||
|
return len(m.users)
|
||||||
|
default:
|
||||||
|
return len(m.sites)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentZonePrefix() (string, int) {
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
return "site", len(m.sites)
|
||||||
|
case 1:
|
||||||
|
return "alert", len(m.alerts)
|
||||||
|
case 4:
|
||||||
|
return "maint", len(m.maintenanceWindows)
|
||||||
|
case 5:
|
||||||
|
return "user", len(m.users)
|
||||||
|
default:
|
||||||
|
return "site", 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sinApprox(x float64) float64 {
|
||||||
|
return math.Sin(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) pulseIndicator() string {
|
||||||
|
hasDown := false
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
|
hasDown = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.demoMode {
|
||||||
|
c := m.theme.Success
|
||||||
|
if hasDown {
|
||||||
|
c = m.theme.Danger
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(c).Render("●")
|
||||||
|
}
|
||||||
|
frame := m.tickCount % len(pulseFrames)
|
||||||
|
brightness := int(m.pulsePos*155) + 100
|
||||||
|
if brightness > 255 {
|
||||||
|
brightness = 255
|
||||||
|
}
|
||||||
|
var color string
|
||||||
|
if hasDown {
|
||||||
|
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
||||||
|
} else {
|
||||||
|
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() string {
|
||||||
|
switch m.state {
|
||||||
|
case stateConfirmDelete:
|
||||||
|
kind := "monitor"
|
||||||
|
switch m.deleteTab {
|
||||||
|
case 1:
|
||||||
|
kind = "alert"
|
||||||
|
case 4:
|
||||||
|
kind = "maintenance window"
|
||||||
|
case 5:
|
||||||
|
kind = "user"
|
||||||
|
}
|
||||||
|
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
|
||||||
|
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(m.theme.Danger).
|
||||||
|
Padding(1, 3).
|
||||||
|
Render(msg + "\n\n" + hint)
|
||||||
|
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
||||||
|
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
||||||
|
if m.huhForm != nil {
|
||||||
|
title := ""
|
||||||
|
switch m.state {
|
||||||
|
case stateFormSite:
|
||||||
|
title = "Add Monitor"
|
||||||
|
if m.editID > 0 {
|
||||||
|
title = fmt.Sprintf("Edit Monitor #%d", m.editID)
|
||||||
|
}
|
||||||
|
case stateFormAlert:
|
||||||
|
title = "Add Alert"
|
||||||
|
if m.editID > 0 {
|
||||||
|
title = fmt.Sprintf("Edit Alert #%d", m.editID)
|
||||||
|
}
|
||||||
|
case stateFormUser:
|
||||||
|
title = "Add User"
|
||||||
|
if m.editID > 0 {
|
||||||
|
title = fmt.Sprintf("Edit User #%d", m.editID)
|
||||||
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
title = "New Maintenance Window"
|
||||||
|
}
|
||||||
|
formHeight := m.termHeight - 7
|
||||||
|
if formHeight < 5 {
|
||||||
|
formHeight = 5
|
||||||
|
}
|
||||||
|
m.huhForm.WithHeight(formHeight)
|
||||||
|
header := titleStyle.Render(title)
|
||||||
|
footer := subtleStyle.Render("\n[Esc] Cancel")
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case stateDetail:
|
||||||
|
return m.viewDetailPanel()
|
||||||
|
case stateAlertDetail:
|
||||||
|
return m.viewAlertDetailPanel()
|
||||||
|
default:
|
||||||
|
return m.zones.Scan(m.viewDashboard())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dashboardStats struct {
|
||||||
|
totalMonitors int
|
||||||
|
downCount int
|
||||||
|
lateCount int
|
||||||
|
offlineNodes int
|
||||||
|
activeMaint int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) computeStats() dashboardStats {
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
var s dashboardStats
|
||||||
|
for _, site := range allSites {
|
||||||
|
if site.Type == "group" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.totalMonitors++
|
||||||
|
if site.Paused || m.isMonitorInMaintenance(site.ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch site.Status {
|
||||||
|
case "DOWN", "SSL EXP":
|
||||||
|
s.downCount++
|
||||||
|
case "LATE":
|
||||||
|
s.lateCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range m.nodes {
|
||||||
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
|
||||||
|
s.offlineNodes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
now := time.Now()
|
||||||
|
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
||||||
|
s.activeMaint++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewDashboard() string {
|
||||||
|
stats := m.computeStats()
|
||||||
|
|
||||||
|
header := m.renderTabBar(stats)
|
||||||
|
header = m.pulseIndicator() + " " + header
|
||||||
|
|
||||||
|
var content string
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
content = m.viewSitesTab()
|
||||||
|
case 1:
|
||||||
|
content = m.viewAlertsTab()
|
||||||
|
case 2:
|
||||||
|
content = m.viewLogsTab()
|
||||||
|
case 3:
|
||||||
|
content = m.viewNodesTab()
|
||||||
|
case 4:
|
||||||
|
content = m.viewMaintTab()
|
||||||
|
case 5:
|
||||||
|
if m.isAdmin {
|
||||||
|
content = m.viewUsersTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := m.renderFooter(stats)
|
||||||
|
|
||||||
|
s := lipgloss.NewStyle().Padding(1, 2)
|
||||||
|
if m.termHeight > 0 {
|
||||||
|
s = s.MaxHeight(m.termHeight)
|
||||||
|
}
|
||||||
|
return s.Render(header + "\n" + content + "\n" + footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderTabBar(stats dashboardStats) string {
|
||||||
|
var sitesLabel string
|
||||||
|
if stats.downCount > 0 {
|
||||||
|
sitesLabel = fmt.Sprintf("Sites (%d↓)", stats.downCount)
|
||||||
|
} else if stats.lateCount > 0 {
|
||||||
|
sitesLabel = fmt.Sprintf("Sites (%d⚠)", stats.lateCount)
|
||||||
|
} else if stats.totalMonitors > 0 {
|
||||||
|
sitesLabel = fmt.Sprintf("Sites (%d)", stats.totalMonitors)
|
||||||
|
} else {
|
||||||
|
sitesLabel = "Sites"
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodesLabel string
|
||||||
|
if stats.offlineNodes > 0 {
|
||||||
|
nodesLabel = fmt.Sprintf("Nodes (%d!)", stats.offlineNodes)
|
||||||
|
} else if len(m.nodes) > 0 {
|
||||||
|
nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes))
|
||||||
|
} else {
|
||||||
|
nodesLabel = "Nodes"
|
||||||
|
}
|
||||||
|
|
||||||
|
var maintLabel string
|
||||||
|
if stats.activeMaint > 0 {
|
||||||
|
maintLabel = fmt.Sprintf("Maint (%d)", stats.activeMaint)
|
||||||
|
} else {
|
||||||
|
maintLabel = "Maint"
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
||||||
|
if m.isAdmin {
|
||||||
|
tabs = append(tabs, "Users")
|
||||||
|
}
|
||||||
|
var renderedTabs []string
|
||||||
|
for i, t := range tabs {
|
||||||
|
var rendered string
|
||||||
|
if i == m.currentTab {
|
||||||
|
rendered = activeTab.Render(t)
|
||||||
|
} else {
|
||||||
|
rendered = inactiveTab.Render(t)
|
||||||
|
}
|
||||||
|
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderFooter(stats dashboardStats) string {
|
||||||
|
if m.filterMode {
|
||||||
|
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
||||||
|
return "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
|
||||||
|
var upStr string
|
||||||
|
if stats.downCount > 0 {
|
||||||
|
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
||||||
|
} else if stats.lateCount > 0 {
|
||||||
|
upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
||||||
|
} else {
|
||||||
|
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
|
||||||
|
}
|
||||||
|
statusParts := []string{upStr}
|
||||||
|
if stats.lateCount > 0 {
|
||||||
|
statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
|
||||||
|
}
|
||||||
|
if len(m.nodes) > 0 {
|
||||||
|
online := 0
|
||||||
|
for _, n := range m.nodes {
|
||||||
|
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
||||||
|
online++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probeLabel := "probes"
|
||||||
|
if online == 1 {
|
||||||
|
probeLabel = "probe"
|
||||||
|
}
|
||||||
|
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
||||||
|
}
|
||||||
|
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
||||||
|
|
||||||
|
var keys string
|
||||||
|
switch m.currentTab {
|
||||||
|
case 0:
|
||||||
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
case 1:
|
||||||
|
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
case 2:
|
||||||
|
keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
case 4:
|
||||||
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
case 5:
|
||||||
|
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
||||||
|
default:
|
||||||
|
keys = "[T]Theme [Tab]Switch [q]Quit"
|
||||||
|
}
|
||||||
|
|
||||||
|
footer := "\n" + statusLine + " " + subtleStyle.Render(keys)
|
||||||
|
if m.filterText != "" && m.currentTab == 0 {
|
||||||
|
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
|
||||||
|
}
|
||||||
|
return footer
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) viewDetailPanel() string {
|
||||||
|
if m.cursor >= len(m.sites) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
site := m.sites[m.cursor]
|
||||||
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
var breadcrumb string
|
||||||
|
if site.ParentID > 0 {
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ID == site.ParentID {
|
||||||
|
breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if breadcrumb == "" {
|
||||||
|
breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
|
||||||
|
}
|
||||||
|
b.WriteString(breadcrumb + "\n\n")
|
||||||
|
|
||||||
|
row := func(label, value string) {
|
||||||
|
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
section := func(label string) {
|
||||||
|
b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
||||||
|
|
||||||
|
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
|
||||||
|
row("Error", dangerStyle.Render(limitStr(site.LastError, 60)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.Type == "http" && site.StatusCode > 0 {
|
||||||
|
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !site.StatusChangedAt.IsZero() {
|
||||||
|
dur := time.Since(site.StatusChangedAt)
|
||||||
|
row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !site.LastSuccessAt.IsZero() {
|
||||||
|
ago := time.Since(site.LastSuccessAt)
|
||||||
|
row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
|
row("Maintenance", maintStyle.Render(mw.Title))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section("ENDPOINT")
|
||||||
|
row("Type", site.Type)
|
||||||
|
if site.URL != "" {
|
||||||
|
row("URL", site.URL)
|
||||||
|
}
|
||||||
|
if site.Hostname != "" {
|
||||||
|
row("Host", site.Hostname)
|
||||||
|
}
|
||||||
|
if site.Port > 0 {
|
||||||
|
row("Port", strconv.Itoa(site.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
section("TIMING")
|
||||||
|
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
||||||
|
if site.Timeout > 0 {
|
||||||
|
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
||||||
|
}
|
||||||
|
row("Latency", fmtLatency(site.Latency))
|
||||||
|
row("Uptime", fmtUptime(hist.Statuses))
|
||||||
|
if !site.LastCheck.IsZero() {
|
||||||
|
row("Last Check", site.LastCheck.Format("15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.Type == "http" {
|
||||||
|
section("HTTP")
|
||||||
|
if site.Method != "" && site.Method != "GET" {
|
||||||
|
row("Method", site.Method)
|
||||||
|
}
|
||||||
|
codes := site.AcceptedCodes
|
||||||
|
if codes == "" {
|
||||||
|
codes = "200-299"
|
||||||
|
}
|
||||||
|
row("Codes", codes)
|
||||||
|
row("SSL", fmtSSL(site))
|
||||||
|
if site.IgnoreTLS {
|
||||||
|
row("TLS Verify", dangerStyle.Render("disabled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
||||||
|
section("CONFIG")
|
||||||
|
if site.MaxRetries > 0 {
|
||||||
|
row("Retries", fmtRetries(site))
|
||||||
|
}
|
||||||
|
if site.Regions != "" {
|
||||||
|
row("Regions", site.Regions)
|
||||||
|
}
|
||||||
|
if site.Description != "" {
|
||||||
|
row("Description", site.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
probeResults := m.engine.GetProbeResults(site.ID)
|
||||||
|
if len(probeResults) > 0 {
|
||||||
|
b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n")
|
||||||
|
for nodeID, result := range probeResults {
|
||||||
|
status := specialStyle.Render("UP")
|
||||||
|
if !result.IsUp {
|
||||||
|
status = dangerStyle.Render("DN")
|
||||||
|
}
|
||||||
|
latency := time.Duration(result.LatencyNs).Milliseconds()
|
||||||
|
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
||||||
|
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
|
||||||
|
if !result.IsUp && result.ErrorReason != "" {
|
||||||
|
line += " " + dangerStyle.Render(limitStr(result.ErrorReason, 30))
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stateChanges := m.engine.GetStateChanges(site.ID, 5)
|
||||||
|
if len(stateChanges) > 0 {
|
||||||
|
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
||||||
|
for _, sc := range stateChanges {
|
||||||
|
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||||
|
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
||||||
|
if sc.ToStatus == "UP" {
|
||||||
|
arrow += specialStyle.Render(sc.ToStatus)
|
||||||
|
} else {
|
||||||
|
arrow += dangerStyle.Render(sc.ToStatus)
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
||||||
|
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
||||||
|
line += " " + dangerStyle.Render(limitStr(sc.ErrorReason, 40))
|
||||||
|
}
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n")
|
||||||
|
const sparkWidth = 40
|
||||||
|
if site.Type == "push" {
|
||||||
|
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
|
||||||
|
if len(hist.Statuses) > 0 {
|
||||||
|
up := 0
|
||||||
|
for _, s := range hist.Statuses {
|
||||||
|
if s {
|
||||||
|
up++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n %s %d/%d checks up",
|
||||||
|
subtleStyle.Render("Heartbeats"),
|
||||||
|
up, len(hist.Statuses))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
||||||
|
var minL, maxL, total time.Duration
|
||||||
|
count := 0
|
||||||
|
for i, l := range hist.Latencies {
|
||||||
|
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
minL, maxL = l, l
|
||||||
|
} else if l < minL {
|
||||||
|
minL = l
|
||||||
|
} else if l > maxL {
|
||||||
|
maxL = l
|
||||||
|
}
|
||||||
|
total += l
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
avg := total / time.Duration(count)
|
||||||
|
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
||||||
|
subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
|
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
|
subtleStyle.Render("Max"), maxL.Milliseconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit"))
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user