feat(tui): inline detail panel, titled borders, panel focus #146

Merged
lerko merged 10 commits from feat/inline-detail into main 2026-06-21 16:44:01 +00:00
16 changed files with 442 additions and 98 deletions
+5
View File
@@ -86,6 +86,11 @@ type ProbeNode struct {
Version string Version string
} }
type LogEntry struct {
Message string
CreatedAt time.Time
}
// AlertHealthRecord is the persisted send health of an alert channel. It lets the // AlertHealthRecord is the persisted send health of an alert channel. It lets the
// "last sent" / health indicators survive restarts instead of resetting to "never". // "last sent" / health indicators survive restarts instead of resetting to "never".
type AlertHealthRecord struct { type AlertHealthRecord struct {
+13 -11
View File
@@ -40,7 +40,7 @@ type Engine struct {
liveState map[int]models.Site liveState map[int]models.Site
logMu sync.RWMutex logMu sync.RWMutex
logStore []string logStore []models.LogEntry
activeMu sync.RWMutex activeMu sync.RWMutex
isActive bool isActive bool
@@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string {
// appendLog adds a timestamped entry to the in-memory ring buffer and returns // appendLog adds a timestamped entry to the in-memory ring buffer and returns
// it. It never touches the database, so it is safe to call from the db-write // it. It never touches the database, so it is safe to call from the db-write
// drop/error path without recursing back through the write queue. // drop/error path without recursing back through the write queue.
func (e *Engine) appendLog(msg string) string { func (e *Engine) appendLog(msg string) models.LogEntry {
ts := time.Now().Format("15:04:05") entry := models.LogEntry{
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg)) Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock() e.logMu.Lock()
e.logStore = append([]string{entry}, e.logStore...) e.logStore = append([]models.LogEntry{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries { if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries] e.logStore = e.logStore[:maxLogEntries]
} }
@@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string {
func (e *Engine) AddLog(msg string) { func (e *Engine) AddLog(msg string) {
entry := e.appendLog(msg) entry := e.appendLog(msg)
e.enqueueWrite(writeLog{message: entry}) e.enqueueWrite(writeLog{message: entry.Message})
} }
// enqueueWrite hands a persistence task to the writer goroutine without // enqueueWrite hands a persistence task to the writer goroutine without
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
} }
func (e *Engine) InitLogs() { func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries) entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil { if err != nil {
return return
} }
if len(logs) == 0 { if len(entries) == 0 {
return return
} }
e.logMu.Lock() e.logMu.Lock()
defer e.logMu.Unlock() defer e.logMu.Unlock()
e.logStore = logs e.logStore = entries
} }
// InitAlertHealth restores persisted alert send health so the dashboard shows real // InitAlertHealth restores persisted alert send health so the dashboard shows real
@@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() {
} }
} }
func (e *Engine) GetLogs() []string { func (e *Engine) GetLogs() []models.LogEntry {
e.logMu.RLock() e.logMu.RLock()
defer e.logMu.RUnlock() defer e.logMu.RUnlock()
logs := make([]string, len(e.logStore)) logs := make([]models.LogEntry, len(e.logStore))
copy(logs, e.logStore) copy(logs, e.logStore)
return logs return logs
} }
+9 -6
View File
@@ -25,7 +25,7 @@ type mockStore struct {
sites []models.SiteConfig sites []models.SiteConfig
alerts map[int]models.AlertConfig alerts map[int]models.AlertConfig
maintenance map[int]bool maintenance map[int]bool
logs []string logs []models.LogEntry
history map[int][]models.CheckRecord history map[int][]models.CheckRecord
savedChecks []savedCheck savedChecks []savedCheck
savedLogs []string savedLogs []string
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
return nil return nil
} }
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) {
return m.logs, nil return m.logs, nil
} }
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
logs := e.GetLogs() logs := e.GetLogs()
found := false found := false
for _, l := range logs { for _, l := range logs {
if containsStr(l, "suppressed") { if containsStr(l.Message, "suppressed") {
found = true found = true
break break
} }
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
if len(logs) != 100 { if len(logs) != 100 {
t.Errorf("expected 100 logs, got %d", len(logs)) t.Errorf("expected 100 logs, got %d", len(logs))
} }
if !containsStr(logs[0], "log-104") { if !containsStr(logs[0].Message, "log-104") {
t.Errorf("expected newest log first, got %s", logs[0]) t.Errorf("expected newest log first, got %s", logs[0].Message)
} }
} }
func TestInitLogs_LoadsFromDB(t *testing.T) { func TestInitLogs_LoadsFromDB(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.logs = []string{"old-log-1", "old-log-2"} ms.logs = []models.LogEntry{
{Message: "old-log-1"},
{Message: "old-log-2"},
}
e := newTestEngine(ms) e := newTestEngine(ms)
e.InitLogs() e.InitLogs()
+15 -8
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
return err return err
} }
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) { func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var logs []string var entries []models.LogEntry
for rows.Next() { for rows.Next() {
var msg string var e models.LogEntry
if err := rows.Scan(&msg); err != nil { if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
return logs, err return entries, err
} }
logs = append(logs, msg) // Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
if len(e.Message) > 3 && e.Message[0] == '[' {
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
e.Message = strings.TrimSpace(e.Message[idx+1:])
}
}
entries = append(entries, e)
} }
return logs, rows.Err() return entries, rows.Err()
} }
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
+1 -1
View File
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
// LoadLogs ordering ties when rows share a created_at second). // LoadLogs ordering ties when rows share a created_at second).
present := make(map[string]bool, len(logs)) present := make(map[string]bool, len(logs))
for _, l := range logs { for _, l := range logs {
present[l] = true present[l.Message] = true
} }
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] { if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
t.Error("newest log was pruned") t.Error("newest log was pruned")
+1 -1
View File
@@ -61,7 +61,7 @@ type Store interface {
// Logs // Logs
SaveLog(ctx context.Context, message string) error SaveLog(ctx context.Context, message string) error
LoadLogs(ctx context.Context, limit int) ([]string, error) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
PruneLogs(ctx context.Context) error PruneLogs(ctx context.Context) error
// Maintenance Windows // Maintenance Windows
+2 -2
View File
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
return nil return nil
} }
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
if m.GetActiveMaintenanceWindowsFunc != nil { if m.GetActiveMaintenanceWindowsFunc != nil {
+52
View File
@@ -0,0 +1,52 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanel(title, content string, width int, focused bool) string {
borderColor := m.theme.Border
titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
}
bc := lipgloss.NewStyle().Foreground(borderColor)
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
innerW := width - 2
if innerW < 10 {
innerW = 10
}
titleRendered := tc.Render(" " + title + " ")
titleLen := len([]rune(title)) + 2
fillLen := innerW - titleLen - 1
if fillLen < 0 {
fillLen = 0
}
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
var lines []string
lines = append(lines, top)
for _, line := range strings.Split(inner, "\n") {
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
}
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
return strings.Join(lines, "\n")
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+12 -25
View File
@@ -3,6 +3,8 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type logSeverity int type logSeverity int
@@ -15,8 +17,8 @@ const (
severitySystem severitySystem
) )
func classifyLog(line string) logSeverity { func classifyLog(msg string) logSeverity {
lower := strings.ToLower(line) lower := strings.ToLower(msg)
switch { switch {
case strings.Contains(lower, "confirmed down"), case strings.Contains(lower, "confirmed down"),
strings.Contains(lower, "is down"), strings.Contains(lower, "is down"),
@@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string {
} }
} }
func (m Model) renderLogLine(line string) string { func (m Model) renderLogLine(entry models.LogEntry) string {
sev := classifyLog(line) sev := classifyLog(entry.Message)
tag := m.renderLogTag(sev) tag := m.renderLogTag(sev)
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
ts := "" return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
msg := line
if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
ts = m.st.subtleStyle.Render(line[1:idx])
msg = strings.TrimSpace(line[idx+1:])
}
}
if ts != "" {
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
}
return fmt.Sprintf(" %s %s", tag, msg)
} }
// refreshLogContent rebuilds the log viewport from the full engine log list,
// filtering before windowing so the entry count and "(n hidden)" reflect all
// logs, not just the visible viewport slice.
func (m *Model) refreshLogContent() { func (m *Model) refreshLogContent() {
var rendered []string var rendered []string
total := 0 total := 0
shown := 0 shown := 0
for _, line := range m.engine.GetLogs() { for _, entry := range m.engine.GetLogs() {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(entry.Message) == "" {
continue continue
} }
total++ total++
sev := classifyLog(line) sev := classifyLog(entry.Message)
if m.logFilterImportant && !isImportantLog(sev) { if m.logFilterImportant && !isImportantLog(sev) {
continue continue
} }
shown++ shown++
rendered = append(rendered, m.renderLogLine(line)) rendered = append(rendered, m.renderLogLine(entry))
} }
m.logTotal = total m.logTotal = total
+49 -26
View File
@@ -3,11 +3,12 @@ package tui
import ( import (
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func (m Model) renderCompactLogLine(line string, maxW int) string { func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
sev := classifyLog(line) sev := classifyLog(entry.Message)
var tag string var tag string
switch sev { switch sev {
@@ -23,36 +24,23 @@ func (m Model) renderCompactLogLine(line string, maxW int) string {
tag = m.st.subtleStyle.Render("·") tag = m.st.subtleStyle.Render("·")
} }
ts := "" ts := entry.CreatedAt.Local().Format("01/02 15:04")
msg := line
if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
ts = line[1:idx]
msg = strings.TrimSpace(line[idx+1:])
}
}
msg := entry.Message
msg = strings.TrimPrefix(msg, "Monitor ") msg = strings.TrimPrefix(msg, "Monitor ")
msg = strings.TrimPrefix(msg, "Push ") msg = strings.TrimPrefix(msg, "Push ")
// prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp prefixW := len(ts) + 4
prefixW := 3
if ts != "" {
prefixW = len(ts) + 4
}
msgW := maxW - prefixW msgW := maxW - prefixW
if msgW < 5 { if msgW < 5 {
msgW = 5 msgW = 5
} }
msg = limitStr(msg, msgW) msg = limitStr(msg, msgW)
if ts != "" { return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
return " " + tag + " " + msg
} }
func (m Model) viewLogsSidebar(width int) string { func (m Model) viewLogsSidebar(width, maxLines int) string {
logs := m.engine.GetLogs() logs := m.engine.GetLogs()
if len(logs) == 0 { if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet") return m.st.subtleStyle.Render(" No logs yet")
@@ -60,16 +48,51 @@ func (m Model) viewLogsSidebar(width int) string {
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width) sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
var lines []string var all []string
for _, line := range logs { for _, entry := range logs {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(entry.Message) == "" {
continue continue
} }
if m.logFilterImportant && !isImportantLog(classifyLog(line)) { if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue continue
} }
lines = append(lines, m.renderCompactLogLine(line, width)) all = append(all, m.renderCompactLogLine(entry, width))
} }
return "\n" + sidebarStyle.Render(strings.Join(lines, "\n")) start := m.logScrollOffset
if start > len(all) {
start = len(all)
}
end := start + maxLines
if end > len(all) {
end = len(all)
}
visible := all[start:end]
return sidebarStyle.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) {
logs := m.engine.GetLogs()
total := 0
for _, entry := range logs {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue
}
total++
}
m.logScrollOffset += delta
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
if m.logScrollOffset > total-1 {
m.logScrollOffset = total - 1
}
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
} }
+2 -3
View File
@@ -52,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
} }
t := table.New(). t := table.New().
Border(lipgloss.RoundedBorder()). Border(lipgloss.HiddenBorder()).
BorderStyle(m.st.tableBorderStyle).
Width(tableWidth). Width(tableWidth).
Headers(headers...). Headers(headers...).
Rows(rows...). Rows(rows...).
@@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return base return base
}) })
return "\n" + t.Render() return t.Render()
} }
+12 -1
View File
@@ -96,6 +96,12 @@ const (
sectionUsers = 2 sectionUsers = 2
) )
const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
)
type sessionState int type sessionState int
const ( const (
@@ -124,6 +130,8 @@ type Model struct {
termWidth int termWidth int
termHeight int termHeight int
contentWidth int contentWidth int
focusedPanel int
logScrollOffset int
editID int editID int
editToken string editToken string
@@ -179,7 +187,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load tabSeq int // seq of the newest issued tab-data load
// detail-panel state-change history, loaded on enter so View does no DB IO detailOpen bool
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
@@ -211,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
} }
} }
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -224,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
sparkTooltipIdx: -1, sparkTooltipIdx: -1,
+116 -5
View File
@@ -142,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
const detailInlineHeight = 8
func (m *Model) recalcLayout() { func (m *Model) recalcLayout() {
chrome := chromeBase chrome := chromeBase
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
if m.detailOpen && m.currentTab == tabMonitors {
chrome += detailInlineHeight
}
m.maxTableRows = m.termHeight - chrome m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
@@ -269,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp {
m.scrollLogs(-3)
} else {
m.scrollLogs(3)
}
return m, nil
}
listLen := m.currentListLen() listLen := m.currentListLen()
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 { if m.cursor > 0 {
@@ -286,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
@@ -517,23 +534,45 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "left", "h": case "left":
if m.currentTab == tabSettings { if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection - 1) m.switchSettingsSection(m.settingsSection - 1)
} }
case "right", "l": case "right":
if m.currentTab == tabSettings { if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection + 1) m.switchSettingsSection(m.settingsSection + 1)
} }
case "l":
switch m.currentTab {
case tabSettings:
m.switchSettingsSection(m.settingsSection + 1)
case tabMonitors:
if m.focusedPanel == panelLogs {
m.focusedPanel = panelMonitors
} else {
m.focusedPanel = panelLogs
}
}
case "up", "k": case "up", "k":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(-1)
return m, nil
}
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
m.tableOffset = m.cursor m.tableOffset = m.cursor
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
} }
case "down", "j": case "down", "j":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(1)
return m, nil
}
max := m.currentListLen() - 1 max := m.currentListLen() - 1
if m.cursor < max { if m.cursor < max {
m.cursor++ m.cursor++
@@ -541,10 +580,19 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tableOffset++ m.tableOffset++
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
} }
case "n": case "n":
return m.handleNewItem() return m.handleNewItem()
case "e", "enter": case "enter":
if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m.handleEditItem()
case "e":
return m.handleEditItem() return m.handleEditItem()
case "t": case "t":
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
@@ -574,11 +622,59 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case "i": case "i":
if m.currentTab == tabMonitors && len(m.sites) > 0 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail m.detailOpen = !m.detailOpen
return m, m.loadDetailCmd(m.sites[m.cursor].ID) m.recalcLayout()
st := m.store
open := m.detailOpen
var cmd tea.Cmd
if m.detailOpen {
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
}
saveCmd := writeCmd("Save detail preference", func() error {
v := "false"
if open {
v = "true"
}
return st.SetPreference(context.Background(), "detail_open", v)
})
if cmd != nil {
return m, tea.Batch(cmd, saveCmd)
}
return m, saveCmd
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 { } else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
m.state = stateAlertDetail m.state = stateAlertDetail
} }
case "esc":
if m.currentTab == tabMonitors {
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen {
m.detailOpen = false
m.recalcLayout()
st := m.store
return m, writeCmd("Save detail preference", func() error {
return st.SetPreference(context.Background(), "detail_open", "false")
})
}
}
case "h":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "x": case "x":
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 { if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
mw := m.maintenanceWindows[m.cursor] mw := m.maintenanceWindows[m.cursor]
@@ -707,6 +803,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
if m.currentTab == tabMonitors {
if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors
} else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs
return m, nil
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
m.focusedPanel = panelDetail
return m, nil
}
}
prefix, listLen := m.currentZonePrefix() prefix, listLen := m.currentZonePrefix()
end := m.tableOffset + m.maxTableRows end := m.tableOffset + m.maxTableRows
if end > listLen { if end > listLen {
@@ -716,6 +824,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
m.cursor = i m.cursor = i
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
} }
+1 -1
View File
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
mm := updated.(Model) mm := updated.(Model)
found := false found := false
for _, line := range mm.engine.GetLogs() { for _, line := range mm.engine.GetLogs() {
if strings.Contains(line, "Delete site failed: boom") { if strings.Contains(line.Message, "Delete site failed: boom") {
found = true found = true
} }
} }
+38 -8
View File
@@ -157,15 +157,39 @@ func (m Model) viewDashboard() string {
availW := m.termWidth - chromePadH availW := m.termWidth - chromePadH
leftW := availW * 70 / 100 leftW := availW * 70 / 100
rightW := availW - leftW rightW := availW - leftW
m.contentWidth = leftW m.contentWidth = leftW - 2
monitors := m.viewSitesTab() monitors := m.viewSitesTab()
left := lipgloss.NewStyle().Width(leftW).Render(monitors) monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
sidebar := m.viewLogsSidebar(rightW) sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
right := lipgloss.NewStyle().Width(rightW).Render(sidebar) logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
content = lipgloss.JoinHorizontal(lipgloss.Top, left, right) top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = top + "\n" + detailPanel
} else {
content = top
}
} else { } else {
m.contentWidth = m.termWidth m.contentWidth = m.termWidth - 2
content = m.viewSitesTab() monitors := m.viewSitesTab()
availW := m.termWidth - chromePadH
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = monPanel + "\n" + detailPanel
} else {
content = monPanel
}
} }
case tabMaint: case tabMaint:
m.contentWidth = m.termWidth m.contentWidth = m.termWidth
@@ -279,7 +303,13 @@ func (m Model) renderFooter(stats dashboardStats) string {
var keys string var keys string
switch m.currentTab { switch m.currentTab {
case tabMonitors: case tabMonitors:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" if m.focusedPanel == panelLogs {
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
} else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else {
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
}
case tabMaint: case tabMaint:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case tabSettings: case tabSettings:
+114
View File
@@ -0,0 +1,114 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailInline(width int) string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
latency := m.fmtLatency(site.Latency)
uptime := m.fmtUptime(hist.Statuses)
line1Parts := []string{status}
if site.Latency > 0 {
line1Parts = append(line1Parts, latency)
}
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
if !site.LastCheck.IsZero() {
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
}
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := width - 12
if errW < 20 {
errW = 20
}
errMsg := limitStr(site.LastError, errW)
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
}
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
var parts []string
limit := 3
if len(stateChanges) < limit {
limit = len(stateChanges)
}
for _, sc := range stateChanges[:limit] {
ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
to := m.fmtStatusWord(sc.ToStatus)
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
if sc.ErrorReason != "" {
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
}
parts = append(parts, entry)
}
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
}
if len(hist.Latencies) > 0 {
sparkW := width - 30
if sparkW < 10 {
sparkW = 10
}
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
minMs := hist.Latencies[0].Milliseconds()
maxMs := hist.Latencies[0].Milliseconds()
var sumMs int64
for _, l := range hist.Latencies {
ms := l.Milliseconds()
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
}
avgMs := sumMs / int64(len(hist.Latencies))
stats := fmt.Sprintf("Min %s Avg %s Max %s",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
b.WriteString(" " + spark + " " + stats + "\n")
}
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
b.WriteString(" " + keys + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) fmtStatusWord(status string) string {
switch status {
case "DOWN":
return m.st.dangerStyle.Render("DOWN")
case "UP":
return m.st.specialStyle.Render("UP")
default:
return m.st.subtleStyle.Render(status)
}
}