fix(tui): visual polish and layout improvements #18
@@ -62,6 +62,8 @@ func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { retur
|
||||
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||
|
||||
func TestMetricsHandler(t *testing.T) {
|
||||
ms := &mockStore{
|
||||
|
||||
@@ -67,6 +67,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -441,6 +441,24 @@ func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetPreference(key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) SetPreference(key, value string) error {
|
||||
if s.dollar {
|
||||
_, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) ExportData() (models.Backup, error) {
|
||||
sites, err := s.GetSites()
|
||||
if err != nil {
|
||||
|
||||
@@ -57,6 +57,10 @@ type Store interface {
|
||||
DeleteMaintenanceWindow(id int) error
|
||||
IsMonitorInMaintenance(monitorID int) (bool, error)
|
||||
|
||||
// Preferences
|
||||
GetPreference(key string) (string, error)
|
||||
SetPreference(key, value string) error
|
||||
|
||||
// Backup & Restore
|
||||
ExportData() (models.Backup, error)
|
||||
ImportData(data models.Backup) error
|
||||
|
||||
@@ -706,8 +706,19 @@ func (m Model) viewDetailPanel() string {
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
title := titleStyle.Render(fmt.Sprintf(" %s", site.Name))
|
||||
b.WriteString(title + "\n\n")
|
||||
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) {
|
||||
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
|
||||
@@ -777,8 +788,37 @@ func (m Model) viewDetailPanel() string {
|
||||
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++
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n %s %d/%d checks up",
|
||||
subtleStyle.Render("Heartbeats"),
|
||||
up, len(hist.Statuses)))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
|
||||
if len(hist.Latencies) > 0 {
|
||||
minL, maxL := hist.Latencies[0], hist.Latencies[0]
|
||||
var total time.Duration
|
||||
for _, l := range hist.Latencies {
|
||||
total += l
|
||||
if l < minL {
|
||||
minL = l
|
||||
}
|
||||
if l > maxL {
|
||||
maxL = l
|
||||
}
|
||||
}
|
||||
avg := total / time.Duration(len(hist.Latencies))
|
||||
b.WriteString(fmt.Sprintf("\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")
|
||||
|
||||
@@ -21,6 +21,10 @@ var (
|
||||
|
||||
tableBorderStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#444"))
|
||||
|
||||
tableZebraStyle = lipgloss.NewStyle().
|
||||
Padding(0, 1).
|
||||
Background(lipgloss.Color("#1a1a2e"))
|
||||
)
|
||||
|
||||
type StyleOverride func(row, col int) *lipgloss.Style
|
||||
@@ -67,6 +71,9 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
||||
}
|
||||
}
|
||||
base := tableCellStyle
|
||||
if row%2 == 1 {
|
||||
base = tableZebraStyle
|
||||
}
|
||||
if isSelected {
|
||||
base = tableSelectedStyle
|
||||
}
|
||||
|
||||
+31
-1
@@ -1,6 +1,7 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go-upkeep/internal/models"
|
||||
"go-upkeep/internal/monitor"
|
||||
@@ -105,6 +106,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
||||
vpLogs.SetContent("Waiting for logs...")
|
||||
z := zone.New()
|
||||
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
||||
collapsed := loadCollapsed(s)
|
||||
return Model{
|
||||
state: stateDashboard,
|
||||
logViewport: vpLogs,
|
||||
@@ -114,10 +116,37 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
||||
engine: eng,
|
||||
zones: z,
|
||||
pulseSpring: spring,
|
||||
collapsed: make(map[int]bool),
|
||||
collapsed: collapsed,
|
||||
}
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
@@ -401,6 +430,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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":
|
||||
|
||||
Reference in New Issue
Block a user