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
}
type LogEntry struct {
Message string
CreatedAt time.Time
}
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
// "last sent" / health indicators survive restarts instead of resetting to "never".
type AlertHealthRecord struct {
+13 -11
View File
@@ -40,7 +40,7 @@ type Engine struct {
liveState map[int]models.Site
logMu sync.RWMutex
logStore []string
logStore []models.LogEntry
activeMu sync.RWMutex
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
// 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.
func (e *Engine) appendLog(msg string) string {
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
func (e *Engine) appendLog(msg string) models.LogEntry {
entry := models.LogEntry{
Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock()
e.logStore = append([]string{entry}, e.logStore...)
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
if len(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) {
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
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
}
func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil {
return
}
if len(logs) == 0 {
if len(entries) == 0 {
return
}
e.logMu.Lock()
defer e.logMu.Unlock()
e.logStore = logs
e.logStore = entries
}
// 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()
defer e.logMu.RUnlock()
logs := make([]string, len(e.logStore))
logs := make([]models.LogEntry, len(e.logStore))
copy(logs, e.logStore)
return logs
}
+9 -6
View File
@@ -25,7 +25,7 @@ type mockStore struct {
sites []models.SiteConfig
alerts map[int]models.AlertConfig
maintenance map[int]bool
logs []string
logs []models.LogEntry
history map[int][]models.CheckRecord
savedChecks []savedCheck
savedLogs []string
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
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
}
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l, "suppressed") {
if containsStr(l.Message, "suppressed") {
found = true
break
}
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
if len(logs) != 100 {
t.Errorf("expected 100 logs, got %d", len(logs))
}
if !containsStr(logs[0], "log-104") {
t.Errorf("expected newest log first, got %s", logs[0])
if !containsStr(logs[0].Message, "log-104") {
t.Errorf("expected newest log first, got %s", logs[0].Message)
}
}
func TestInitLogs_LoadsFromDB(t *testing.T) {
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.InitLogs()
+15 -8
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
return err
}
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
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 {
return nil, err
}
defer rows.Close()
var logs []string
var entries []models.LogEntry
for rows.Next() {
var msg string
if err := rows.Scan(&msg); err != nil {
return logs, err
var e models.LogEntry
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
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) {
+1 -1
View File
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
// LoadLogs ordering ties when rows share a created_at second).
present := make(map[string]bool, len(logs))
for _, l := range logs {
present[l] = true
present[l.Message] = true
}
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
t.Error("newest log was pruned")
+1 -1
View File
@@ -61,7 +61,7 @@ type Store interface {
// Logs
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
// Maintenance Windows
+2 -2
View File
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
return nil
}
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil }
func (m *BaseMock) PruneLogs(_ context.Context) error { return 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) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
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 (
"fmt"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type logSeverity int
@@ -15,8 +17,8 @@ const (
severitySystem
)
func classifyLog(line string) logSeverity {
lower := strings.ToLower(line)
func classifyLog(msg string) logSeverity {
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "confirmed down"),
strings.Contains(lower, "is down"),
@@ -63,44 +65,29 @@ func (m Model) renderLogTag(sev logSeverity) string {
}
}
func (m Model) renderLogLine(line string) string {
sev := classifyLog(line)
func (m Model) renderLogLine(entry models.LogEntry) string {
sev := classifyLog(entry.Message)
tag := m.renderLogTag(sev)
ts := ""
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)
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
}
// 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() {
var rendered []string
total := 0
shown := 0
for _, line := range m.engine.GetLogs() {
if strings.TrimSpace(line) == "" {
for _, entry := range m.engine.GetLogs() {
if strings.TrimSpace(entry.Message) == "" {
continue
}
total++
sev := classifyLog(line)
sev := classifyLog(entry.Message)
if m.logFilterImportant && !isImportantLog(sev) {
continue
}
shown++
rendered = append(rendered, m.renderLogLine(line))
rendered = append(rendered, m.renderLogLine(entry))
}
m.logTotal = total
+49 -26
View File
@@ -3,11 +3,12 @@ package tui
import (
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderCompactLogLine(line string, maxW int) string {
sev := classifyLog(line)
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
sev := classifyLog(entry.Message)
var tag string
switch sev {
@@ -23,36 +24,23 @@ func (m Model) renderCompactLogLine(line string, maxW int) string {
tag = m.st.subtleStyle.Render("·")
}
ts := ""
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:])
}
}
ts := entry.CreatedAt.Local().Format("01/02 15:04")
msg := entry.Message
msg = strings.TrimPrefix(msg, "Monitor ")
msg = strings.TrimPrefix(msg, "Push ")
// prefix: " HH:MM ● " = 9 visible chars, or " ● " = 3 without timestamp
prefixW := 3
if ts != "" {
prefixW = len(ts) + 4
}
prefixW := len(ts) + 4
msgW := maxW - prefixW
if msgW < 5 {
msgW = 5
}
msg = limitStr(msg, msgW)
if ts != "" {
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
return " " + tag + " " + msg
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
func (m Model) viewLogsSidebar(width int) string {
func (m Model) viewLogsSidebar(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
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)
var lines []string
for _, line := range logs {
if strings.TrimSpace(line) == "" {
var all []string
for _, entry := range logs {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(line)) {
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
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().
Border(lipgloss.RoundedBorder()).
BorderStyle(m.st.tableBorderStyle).
Border(lipgloss.HiddenBorder()).
Width(tableWidth).
Headers(headers...).
Rows(rows...).
@@ -94,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return base
})
return "\n" + t.Render()
return t.Render()
}
+12 -1
View File
@@ -96,6 +96,12 @@ const (
sectionUsers = 2
)
const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
)
type sessionState int
const (
@@ -124,6 +130,8 @@ type Model struct {
termWidth int
termHeight int
contentWidth int
focusedPanel int
logScrollOffset int
editID int
editToken string
@@ -179,7 +187,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
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
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{
state: stateDashboard,
logViewport: vpLogs,
@@ -224,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
+116 -5
View File
@@ -142,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
const detailInlineHeight = 8
func (m *Model) recalcLayout() {
chrome := chromeBase
if m.filterMode || m.filterText != "" {
chrome++
}
if m.detailOpen && m.currentTab == tabMonitors {
chrome += detailInlineHeight
}
m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 {
m.maxTableRows = 1
@@ -269,6 +274,15 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
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()
if msg.Button == tea.MouseButtonWheelUp {
if m.cursor > 0 {
@@ -286,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
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
}
@@ -517,23 +534,45 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case "tab":
m.switchTab(m.currentTab + 1)
case "left", "h":
case "left":
if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection - 1)
}
case "right", "l":
case "right":
if m.currentTab == tabSettings {
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":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(-1)
return m, nil
}
if m.cursor > 0 {
m.cursor--
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
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":
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(1)
return m, nil
}
max := m.currentListLen() - 1
if m.cursor < max {
m.cursor++
@@ -541,10 +580,19 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tableOffset++
}
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "n":
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()
case "t":
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":
if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
m.detailOpen = !m.detailOpen
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 {
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":
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
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()
end := m.tableOffset + m.maxTableRows
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) {
m.cursor = i
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil
}
}
+1 -1
View File
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
mm := updated.(Model)
found := false
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
}
}
+38 -8
View File
@@ -157,15 +157,39 @@ func (m Model) viewDashboard() string {
availW := m.termWidth - chromePadH
leftW := availW * 70 / 100
rightW := availW - leftW
m.contentWidth = leftW
m.contentWidth = leftW - 2
monitors := m.viewSitesTab()
left := lipgloss.NewStyle().Width(leftW).Render(monitors)
sidebar := m.viewLogsSidebar(rightW)
right := lipgloss.NewStyle().Width(rightW).Render(sidebar)
content = lipgloss.JoinHorizontal(lipgloss.Top, left, right)
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
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 {
m.contentWidth = m.termWidth
content = m.viewSitesTab()
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:
m.contentWidth = m.termWidth
@@ -279,7 +303,13 @@ func (m Model) renderFooter(stats dashboardStats) string {
var keys string
switch m.currentTab {
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:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
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)
}
}