refactor(tui): decompose god files into single-concern modules #53
@@ -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"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"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
|
||||
|
||||
type siteFormData struct {
|
||||
@@ -60,289 +33,6 @@ type siteFormData struct {
|
||||
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 {
|
||||
nameW, sparkW int
|
||||
headers []string
|
||||
@@ -357,12 +47,10 @@ func (m Model) computeLayout() tableLayout {
|
||||
var widths []int
|
||||
|
||||
if wide {
|
||||
// # 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}
|
||||
fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9
|
||||
} else {
|
||||
// # 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}
|
||||
fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5
|
||||
@@ -792,202 +480,3 @@ func (m *Model) submitSiteForm() {
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
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