10 Commits

Author SHA1 Message Date
lerko 01dd53241a refactor: separate log timestamp from message as structured LogEntry
Introduced models.LogEntry{Message, CreatedAt} to replace raw strings
in the log pipeline. Timestamps are now formatted at render time, not
baked into stored messages.

- Engine: appendLog stores LogEntry with time.Now()
- Store: LoadLogs returns []LogEntry, selects created_at from DB
- Store: strips legacy [HH:MM] prefix from pre-refactor DB entries
- TUI: sidebar shows "MM/DD HH:MM" from CreatedAt
- TUI: full log view shows "MM/DD HH:MM" from CreatedAt
- SaveLog still receives plain message string (DB handles timestamp)
2026-06-20 20:04:08 -04:00
lerko 81f8c71b6f feat(tui): persist detail panel state as user preference
Detail open/closed state saved via SetPreference on toggle and
restored on session start. Same pattern as theme persistence —
survives restarts and works per-user over SSH.
2026-06-20 19:50:13 -04:00
lerko 7109b6fa1c feat(tui): panel focus with click, scroll, and keyboard
Click any panel (Monitors, Logs, Detail) to focus it — accent border
follows focus. Mouse wheel scrolls the focused panel.

Keyboard: l toggles log panel focus. Arrow keys scroll logs when
focused, navigate monitors when not. Esc returns focus to monitors.

Log sidebar now supports scroll offset — tracks position across
renders without a viewport. Mouse wheel scrolls 3 lines, keyboard
scrolls 1.
2026-06-20 19:44:35 -04:00
lerko e5ac4a1fec feat(tui): lazygit-style titled panel borders
All panels wrapped in titled rounded borders (╭─ Title ──╮). Focused
panel gets accent-colored border, unfocused panels get muted border.

- Monitors panel: titled "Monitors", focused when detail is closed
- Logs panel: titled "Logs", always unfocused (passive display)
- Detail panel: titled with monitor name, focused when open

Table's own RoundedBorder replaced with HiddenBorder — the titled
panel border provides the visual frame, table uses space-separated
columns internally. Consistent chrome across all panels.
2026-06-20 19:30:59 -04:00
lerko 065d5d74bb fix(tui): remove leading newline from bordered sidebar 2026-06-20 19:24:14 -04:00
lerko 08f14f3af8 feat(tui): bordered log sidebar, Enter for full-screen detail
Log sidebar wrapped in rounded border (no left/bottom edge — shared
with monitors table). Creates visual separation between panels.

Enter on a monitor opens the full-screen detail view (existing
stateDetail) for deep dive — history, SLA, probe results, connection
chain. i stays as inline detail toggle.

Footer key hints now context-sensitive: show h/s/Enter when detail
is open, show full keybindings when closed.
2026-06-20 19:18:57 -04:00
lerko 5720fabdbc fix(tui): limit sidebar height to match table, fix detail clipping
Log sidebar was rendering all lines regardless of table height. When
detail panel was open and table shrank, the sidebar stayed tall, pushing
the detail panel past MaxHeight (clipped to empty). Now sidebar accepts
a maxLines parameter capped to table row count.
2026-06-20 19:13:37 -04:00
lerko 54299583d6 debug: make detail title visible with danger style 2026-06-20 19:08:21 -04:00
lerko c9bd9a5a2e fix(tui): shrink table rows when detail panel is open 2026-06-20 19:02:18 -04:00
lerko 66b0681a76 feat(tui): inline detail panel below monitors table
Press i to toggle a compact detail panel below the monitors+logs
split. Shows status, latency, uptime, state changes, sparkline, and
key hints in ~6 lines. Auto-updates when cursor moves between
monitors. h/s/e keys work from the inline detail for history, SLA,
and edit. Escape closes the panel.

No more full-screen detail takeover for the common case. The old
stateDetail path remains for h/s sub-views which still go full-screen.
2026-06-20 18:58:49 -04: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:])
} }
return logs, rows.Err() }
entries = append(entries, e)
}
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
+1 -1
View File
@@ -212,7 +212,7 @@ 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) {
+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
+48 -25
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 { } else {
m.contentWidth = m.termWidth content = top
content = m.viewSitesTab() }
} else {
m.contentWidth = m.termWidth - 2
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)
}
}