82 Commits

Author SHA1 Message Date
lerko faf7d36c64 refactor(tui): replace # column with colored status dot
CI / test (pull_request) Successful in 2m7s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 1m1s
Drop the row-number column and add a colored status dot as the first
column. Dot uses the same icon/color as the existing status indicators
(▲ green for up, ▼ red for down, ◼ purple for maint, etc). Matches
the maint strip visual pattern. Status text column retained for
explicit label.
2026-06-30 23:38:05 -04:00
lerko 16f0c2eb66 fix(tui): pad monitors panel to fill available height
CI / test (pull_request) Successful in 1m55s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 1m1s
Monitors panel now uses titledPanelH with a target height based on
maxTableRows, filling the space down to the bottom strip. No more
gap between monitors panel and logs/maint strip when fewer monitors
than available rows.
2026-06-30 23:22:51 -04:00
lerko f0d97f5562 feat(tui): click anywhere in row to select monitor or maint item
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
Use panel zone Pos() to compute clicked row from Y offset instead of
per-cell zone matching. Monitors and maint strip both support full-row
click selection.
2026-06-30 23:10:16 -04:00
lerko 0484153103 fix(tui): fix maint strip spacing and ANSI padding
Use rune-based padding instead of fmt %-*s which miscounts ANSI escape
sequences. Remove outer lipgloss width wrapper that added extra lines.
2026-06-30 23:00:56 -04:00
lerko e3d681311f refactor(tui): move maintenance from sidebar to bottom strip
Replace the cramped 22-char maint sidebar with a full-width bottom
strip in the same position as logs. Bottom panel is modal: l for logs,
m for maint, same key again to close. Maint strip shows one row per
window with icon, title, affected monitors, and status/time remaining.

Removes maintSidebarW constant and maintOffset field. Introduces
bottomPanel enum (bottomNone/bottomLogs/bottomMaint) replacing
logsOpen and maintOpen booleans. m key no longer gated behind
wideBreakpoint.
2026-06-30 22:47:50 -04:00
lerko f7303c946c fix(tui): show — for uptime when monitor is in maintenance
CI / test (pull_request) Successful in 1m57s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 55s
Monitors in active maintenance now display — instead of a misleading
0.0% uptime in both the main table and detail sidebar.
2026-06-30 21:33:39 -04:00
lerko 6e936ecce3 style(tui): adjust monitor/detail split to 60/40
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Gives monitors panel more room for columns while detail sidebar
content stacks vertically and doesn't need the extra width.
2026-06-30 21:12:00 -04:00
lerko 04cf12f52b style(tui): match detail panel footer to hotbar styling
Detail panel footer now uses accent keys, muted descriptions, and dot
separators — same visual style as the bottom hotbar.
2026-06-30 21:07:47 -04:00
lerko 33df597dda fix(tui): merge SLA footer into single line
Period keys and Esc Back now render on one line instead of two.
2026-06-30 20:59:04 -04:00
lerko 1d14f640f4 feat(tui): pin detail panel footer keys at bottom of sidebar
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 1m1s
Footer keys (period selector, edit/history/SLA shortcuts, Esc) are now
pinned at the bottom of the detail panel, always visible regardless of
scroll position. Scrollable content fills the space between the header
and the pinned footer.
2026-06-30 20:46:43 -04:00
lerko 631f07c242 feat(tui): stable detail panel height with scroll support
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
Detail sidebar now maintains consistent height matching the monitors
panel across all modes (default, SLA, history). Content scrolls within
the fixed frame via mouse wheel or j/k when the detail panel is
focused. Scroll offset resets on mode/monitor/period changes. Logs
strip stays visible in all states.
2026-06-30 20:36:25 -04:00
lerko 0badc2ddf5 fix(tui): cap detail sidebar height to match monitors panel
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
SLA daily breakdown no longer overflows past the monitors panel. Detail
sidebar height is capped to monitors panel height via titledPanelH.
SLA daily rows are limited to fit within available space. Logs strip
stays visible in all detail modes.
2026-06-30 20:18:39 -04:00
lerko be14436701 style(tui): accent keys, muted descriptions, dot separators in hotbar
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Keys rendered in accent color, descriptions in muted. Dot separators
between items. No brackets. Faster to scan at a glance.
2026-06-30 19:56:16 -04:00
lerko efa8894b18 refactor(tui): unify detail toggle on Enter, remove i keybind
Enter now toggles detail sidebar open/closed with preference
persistence. Removes redundant i keybind that did the same thing.
Hotbar updated: [Enter]Detail when closed, [Enter]Close when open.
2026-06-30 19:51:44 -04:00
lerko ef54b36e0d refactor(tui): make Enter always open detail, Space for group collapse
Enter now consistently opens the detail sidebar for both monitors and
groups. Group collapse/expand is Space-only. Hotbar shows [Space]Expand
or [Space]Collapse contextually when cursor is on a group row.
2026-06-30 19:09:51 -04:00
lerko 835844314e fix(tui): show context-aware Enter hint in hotbar
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 50s
When cursor is on a group row, hotbar now shows [Enter]Expand or
[Enter]Collapse instead of [Enter]Detail, matching actual behavior.
2026-06-30 17:09:58 -04:00
lerko dc79e2baaa refactor(tui): remove full-page detail, SLA, and history views
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
All monitor detail content now lives in the sidebar panel.
Delete the full-page viewDetailPanel, viewSLAPanel, viewHistoryPanel,
handleDetailKey, handleSLAKey, handleHistoryKey, openSLAView, and
related dead code (histogram, sparkline tooltip, detailViewport).

Remove stateDetail, stateSLA, stateHistory session states.
Update tests to use detailOpen/detailMode instead of removed states.
2026-06-30 09:28:12 -04:00
lerko 3e02833df4 feat(tui): enter opens detail sidebar, logs as bottom strip
Enter on a site now opens the detail sidebar instead of a
full-page takeover. Enter on a group expands/collapses children
(same as space). Logs panel moves to a toggleable bottom strip
via the l key with j/k scrolling when focused.
2026-06-30 09:12:13 -04:00
lerko 8581662237 feat(tui): SLA and history as sidebar detail modes
SLA and History views now render inside the detail sidebar panel
instead of taking over the full screen. Press s/h to switch modes,
Esc to return to default detail view. Cursor movement resets to
default mode.

- Add detailMode state (detailDefault/detailSLA/detailHistory)
- SLA sidebar: period selector, uptime bars, daily breakdown
- History sidebar: sparkline density, transition list, stats
- Panel title shows "SLA · name" or "History · name" in sub-modes
- Period keys [1]-[4] work in SLA mode from dashboard state
2026-06-30 09:09:04 -04:00
lerko 2779f9f532 feat(tui): tall-narrow detail sidebar for master-detail layout
Replace the old two-column/single-column inline detail with a
vertically stacked layout optimized for the right-side panel.

Content stack: status line, type details, uptime, error, latency
chart (3-row), 30d timeline, sparkline, min/avg/max stats, and
up to 5 state changes — all flowing top-to-bottom within the
~55% width detail panel.
2026-06-30 09:03:30 -04:00
lerko 4321e094a3 feat(tui): move detail panel from bottom overlay to right side
Restructure dashboard from [Monitors 70% | Logs 30%] to
[Monitors 45% | Detail 55%] master-detail layout. Detail panel
now sits beside the monitor list instead of below it, keeping
dashboard context visible during inspection.

- Detail panel renders in horizontal split (right of monitors)
- Logs sidebar removed; `l` key opens fullscreen logs directly
- recalcLayout no longer subtracts detail height from table rows
- Remove unused renderCompactLogLine/viewLogsSidebar (Phase 4 recreates)
2026-06-30 08:59:17 -04:00
lerko 1856820c3e feat(tui): single-dashboard layout with sidebar and overlay
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 50s
Replace 3-tab navigation with a single monitoring dashboard.

- Replace tab bar with minimal status line (pulse + counts + version)
- Maintenance becomes a toggleable left sidebar (m key)
  - Compact list: active (●) and scheduled (○) windows
  - Enter opens full-screen detail with end/delete actions
- Settings becomes a centered floating overlay (S key)
  - Alerts/Nodes/Users sub-sections with left/right navigation
  - Full CRUD support inside overlay with returnState tracking
- Remove currentTab, switchTab, tab constants, tab click zones
- Replace deleteTab with deleteKind string for cleaner dispatch
- Separate settingsCursor/settingsOffset from main cursor
- Panel focus: maint → monitors → logs → detail
- Context-sensitive footer key hints per focused panel
2026-06-29 16:12:53 -04:00
lerko aeee2cd0ce fix(tui): cohesion polish across all views
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
- Hide nonsense latency/histogram data for group monitors in detail view
- Add missing status colors to fmtStatusWord (LATE, STALE, PENDING, etc)
- Wrap Maint and Settings tabs in titled panels to match Monitors tab
- Standardize headers: breadcrumb style (History > Name, SLA > Name)
- Standardize footer key order: actions first, [q/Esc] Back last
- Add # column to Nodes table for consistency with all other tables
- Use titleStyle for section headers (CONFIGURATION, DAILY BREAKDOWN)
- Fix panel focus not switching when clicking monitor rows from logs panel
2026-06-29 11:06:58 -04:00
lerko 7ce5af5bf6 fix(tui): correct delete confirm labels, normalize help text
CI / test (pull_request) Successful in 1m52s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
Delete confirmation dialog used wrong magic numbers for entity kind
mapping — maintenance windows showed as "alert", settings deletions
showed as "monitor". Now uses tab/section constants to match correctly.

Also: consistent [q/Esc] Back across all views (detail inline, logs,
SLA, history headers), extract detailKeys(), remove dead maxSections
variable, add nodes empty state hint, use models.Status constants in
siteOrder, delete orphaned theme.png.
2026-06-29 09:44:51 -04:00
lerko 18e2d60210 feat(tui): mouse click for settings sections, column sort, logs panel
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
- Click Alerts/Nodes/Users sub-tabs in settings to switch sections
- Click STATUS/NAME/LATENCY column headers to sort (click again
  to reverse direction)
- Logs panel show/hide: [l] toggles sidebar visibility, click
  focused panel to hide. Monitors expand to full width when hidden.
- Logs full-screen: Enter when focused on logs opens scrollable
  full-screen log view with severity tags, filter toggle [f],
  and live updates
2026-06-28 12:24:52 -04:00
lerko ab1194f74d fix(monitor): refresh maintenance cache after loading sites
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Move refreshMaintenanceCache() to after sites are loaded into
liveState. Previously it ran before the site loading loop, so on
the first iteration liveState was empty and the cache resolved
to nothing. This caused groups to briefly show DOWN on startup
for children that were in maintenance, until the next poll cycle
repopulated the cache.
2026-06-28 11:15:35 -04:00
lerko 25f4b20b87 feat(monitor): save state changes for group monitors
CI / test (pull_request) Successful in 1m57s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
checkGroup() now detects status transitions and persists them as
state change records, same as individual monitors. Groups also
get StatusChangedAt updates and log entries for down/recovery
events. This enables history and SLA views for group monitors.
2026-06-28 11:00:25 -04:00
lerko 7171dcd6f8 feat(tui): expand selected alert config below table
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
Show a detail line below the alert table for the selected row
with untruncated config, health status, last sent time, and
send/fail counts. Solves the CONFIG column truncation issue
without requiring the full-screen detail view.
2026-06-28 10:41:25 -04:00
lerko 822eda4dfd feat(tui): two-column detail panel with type-specific enrichment
CI / test (pull_request) Successful in 1m52s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 51s
Redesign the inline detail panel from a single left-aligned column
to a two-column layout that uses the full panel width.

Left column (55%): latency chart, min/avg/max stats, 30-day
uptime timeline.

Right column (45%): status/latency/last-check summary, type-
specific metadata (HTTP response code + SSL expiry, ping hostname,
port target, push interval/last heartbeat, DNS resolver), uptime
percentage + retry count, and up to 5 state changes listed
vertically with error reasons.

Narrow terminals (<80 cols) fall back to the original stacked
single-column layout.
2026-06-28 10:23:45 -04:00
lerko a5fd3aec90 refactor: propagate context.Context through call chains
CI / test (pull_request) Successful in 1m56s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Thread context.Context from callers instead of creating
context.Background() at every call site. Engine stores its
lifecycle ctx for use by triggerAlert goroutines. TUI Model
carries ctx for store operations in Cmd closures. CLI commands
create a root ctx and thread it through openStore, seed
functions, and init methods.

Only two intentional context.Background() remain in non-root
positions: engine constructor default (overridden by Start)
and drainWrites (parent already cancelled at shutdown).
2026-06-27 19:43:44 -04:00
lerko 8f16a09da1 refactor: decompose monitor.go and sqlstore.go into focused files
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
Split monitor.go (1131→350 lines) into alerts.go, checks.go,
sites.go, and maintenance.go by concern. Split sqlstore.go
(833→470 lines) into sqlstore_alerts.go, sqlstore_history.go,
and sqlstore_maintenance.go by domain. Tests move with their
implementation. Pure reorganization — no behavioral changes.
2026-06-27 19:23:34 -04:00
lerko 50f77da131 refactor: extract magic numbers to named constants
CI / test (pull_request) Successful in 1m43s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 51s
Replace inline numeric literals with named constants across 14 files:
server timeouts/rate limits, cluster thresholds/intervals, DB pool
sizes, alert/dial timeouts, TUI uptime thresholds, node status
thresholds, and state history limits.
2026-06-27 15:44:03 -04:00
lerko edbc2beddd fix(test): check errors instead of discarding with bare _
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
Replace bare _ error discards with t.Fatalf checks across
sqlstore_test, crypto_test, server_test, and checker_test.
2026-06-27 11:53:10 -04:00
lerko 3a089e7c1d feat(store): detect overlapping maintenance windows
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 56s
Warn via log when creating a maintenance window that overlaps with an
existing one for the same monitor, parent group, or global scope.
Handles both timed and indefinite windows.
2026-06-27 10:58:43 -04:00
lerko 5cc1a005ea feat(assets): full screenshot refresh for new multi-panel layout
CI / test (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 46s
Recaptured all screenshots and demo GIF reflecting the complete TUI
overhaul: 3-tab layout, titled panel borders, log sidebar, inline
detail with latency chart + uptime timeline, full-screen detail with
two-column layout + response histogram.

New screenshots: fulldetail.png, settings.png, maint.png.
Removed: alerts.png, logs.png (now Settings sub-sections and sidebar).
Updated README screenshot grid to match new layout.
2026-06-26 15:57:36 -04:00
lerko 11344b4ae6 feat(tui): two-column bottom layout, endpoint/status split, clean labels
CI / test (pull_request) Successful in 2m4s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
Bottom section: graphs (latency + histogram) 70% left, state changes
30% right. Top section rearranged: endpoint details left, status +
timing + HTTP right. Bottom section labels use bold accent without
trailing rules. Spacing between top and bottom halves.

Removed unused renderSparkTooltip (zone marks not wired in column
layout — can restore later).
2026-06-24 21:11:04 -04:00
lerko 11b647b952 fix(tui): add spacing between latency graph and histogram 2026-06-24 20:58:44 -04:00
lerko 1268d99f5c feat(tui): styled section headers with trailing rules in detail panel
Section labels (STATE CHANGES, LATENCY, RESPONSE TIME DISTRIBUTION,
PROBE RESULTS) now render in bold accent color with a trailing ───
rule that fills the width. Two-column info block separated by a full
divider below. Creates clear visual boundaries between sections.
2026-06-24 20:56:21 -04:00
lerko fb50184f37 feat(tui): two-column detail layout with viewport scrolling
Refactored full-screen detail panel: status+endpoint on left column,
timing+config on right column. Halves vertical height of the info
section. Content wraps in a viewport when it exceeds terminal height
— scroll with j/k/arrows/pgup/pgdn.

Restored renderSparkTooltip lost in rewrite. Fixed lint warnings.
2026-06-24 20:48:15 -04:00
lerko 99121d07d8 feat(tui): response time distribution histogram in full-screen detail
Horizontal bar chart showing latency distribution across 5 buckets:
0-50ms, 50-100ms, 100-200ms, 200-500ms, 500ms+. Color-coded green
(fast), yellow (medium), red (slow). Shows count per bucket.

Appears in the full-screen detail panel (Enter) after the sparkline
stats. Skipped for push monitors (no latency data).
2026-06-24 20:23:22 -04:00
lerko a59edf8410 feat(tui): 30-day uptime timeline in inline detail panel
Statuspage-style colored bar — one █ per day, colored by uptime:
green (>= 99%), yellow (>= 95%), red (< 95%), gray (no data).
30-day percentage shown to the right.

Daily breakdown computed via ComputeDailyBreakdown from state_changes
and loaded alongside detail data in loadDetailCmd. Auto-updates on
cursor move.
2026-06-24 20:16:01 -04:00
lerko 6799163cd4 fix(tui): apply sort to group children, not just ungrouped monitors
CI / test (pull_request) Successful in 2m4s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
2026-06-24 19:44:50 -04:00
lerko 28fb6c8889 feat(tui): alert failure badge on Settings tab
Settings tab warn count now includes failed alert channels — alerts
where the last send failed (LastSendOK=false, LastSendAt not zero).
Shows as a red badge number alongside offline node count. Surfaces
broken alert channels without navigating to Settings.
2026-06-21 19:10:18 -04:00
lerko d982359f25 feat(tui): column sort with indicator on monitors table
Press < / > to cycle sort column (Status, Name, Latency). Press r to
reverse direction. Sorted column shows ▲/▼ arrow in the header.

Groups always float to top. Sort applies to ungrouped monitors and
group children independently. Default: Status descending (DOWN first).
2026-06-21 19:09:42 -04:00
lerko 4af800a359 feat(tui): multi-row color-coded sparkline chart with Y-axis
CI / test (pull_request) Successful in 1m48s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 56s
Replace ntcharts with custom multi-row sparkline. Each bar is color-
coded by latency threshold (green < 200ms, yellow < 500ms, red > 500ms)
and DOWN checks render in red. 3 rows tall with 24 discrete levels.

Y-axis labels (max/min ms) on the left, Min/Avg/Max stats below.
Zero external dependencies — removed ntcharts.
2026-06-21 17:43:50 -04:00
lerko a8c43bdb8e feat(tui): ntcharts sparkline with Y-axis labels and stats
Replaced streamline chart with ntcharts sparkline (block elements,
auto-scaling). Height=2, Y-axis labels (max/min ms) on the left,
Min/Avg/Max stats below. Denser and more readable than the line chart.
2026-06-21 17:12:25 -04:00
lerko d0805f61c6 fix(tui): size chart width to data point count 2026-06-21 13:41:05 -04:00
lerko ad469c86eb feat(tui): ntcharts latency line chart in inline detail panel
Replace block-element sparkline with ntcharts streamline chart in the
inline detail panel. Renders a 4-row line chart with thin line style
using the theme accent color. Auto-scales Y axis to latency range.

Added github.com/NimbleMarkets/ntcharts v0.5.1 dependency (lipgloss v1
compatible). Min/Avg/Max stats rendered below the chart.
2026-06-21 13:05:02 -04:00
lerko 01dd53241a refactor: separate log timestamp from message as structured LogEntry
CI / test (pull_request) Successful in 2m4s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
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
lerko 060cd24de2 fix(tui): align log sidebar with monitor table top edge
CI / test (pull_request) Successful in 1m42s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 51s
2026-06-20 18:23:52 -04:00
lerko 5c40629987 fix(tui): clamp log sidebar width, strip redundant prefixes
Log lines now hard-clamped to panel width via lipgloss MaxWidth.
Stripped "Monitor " and "Push " prefixes from sidebar messages —
redundant in a monitoring app, saves 8 chars per line. Improved
prefix width calculation to prevent line wrapping at narrow widths.
2026-06-20 18:17:08 -04:00
lerko e12f42fe16 fix(tui): use panel width for table layout in split-pane mode
Table columns were computed from terminal width, causing row wrapping
when the monitors panel only gets 70% of the space. Introduced
contentWidth field set per-tab in viewDashboard. computeLayout,
isWide, and renderTable now use contentWidth for column visibility,
available space, and max table width calculations.

Columns gracefully hide (SSL, RETRIES, TYPE, UPTIME) when the panel
is narrower, matching the existing responsive breakpoint behavior.
2026-06-20 18:14:01 -04:00
lerko 8323d27e7d feat(tui): compact log sidebar with severity icons
Replace full viewLogsTab with compact sidebar renderer for the 70/30
monitors split. Single-char severity icons (▼▲◆●·), truncated messages,
no header chrome. Renders directly from engine logs without viewport.
2026-06-20 18:06:07 -04:00
lerko 047bb237e0 feat(tui): consolidate 6 tabs to 3, add log sidebar
Tab bar: Monitors | Maint | Settings (was 6 tabs).

Settings tab merges Alerts, Nodes, Users as sub-sections with
left/right arrow navigation. Each section keeps its own cursor,
keybindings, and CRUD operations.

Monitors tab now shows a log sidebar at >= 120 cols (70/30 split).
Under 120 cols, monitors render full-width without logs.

- Introduced tab constants (tabMonitors, tabMaint, tabSettings)
- Introduced section constants (sectionAlerts, sectionNodes, sectionUsers)
- Removed stateLogs and stateUsers states
- All magic tab numbers replaced with named constants
2026-06-20 17:59:47 -04:00
lerko 5398cccd44 feat(tui): add divider lines framing the content area
CI / test (pull_request) Successful in 1m47s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
Full-width horizontal rules above and below the content area. Tab bar
sits above the top divider, status/keys bar sits below the bottom
divider. Creates three clear visual zones: navigation, content, status.
2026-06-20 17:09:40 -04:00
lerko 4bf64c3841 chore(assets): recapture with summary stats bars
CI / test (pull_request) Successful in 1m45s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 56s
2026-06-20 16:48:37 -04:00
lerko d760420f7c feat(tui): add summary stats bar below sparse tab tables
Alerts: channel count, type count, total sent, failures.
Nodes: online/total, leader ID, region count.
Maint: active, scheduled, ended counts.

Muted subtitle style — adds useful context without visual noise.
2026-06-20 16:44:12 -04:00
lerko 0e5f2dded5 fix(assets): recapture theme screenshots with top-aligned layout
CI / test (pull_request) Successful in 1m43s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
2026-06-20 15:11:33 -04:00
lerko 07f3cc8e09 fix(tui): revert centering, fix demo GIF pacing
CI / test (pull_request) Successful in 2m1s
CI / lint (pull_request) Successful in 1m6s
CI / vulncheck (pull_request) Successful in 56s
Revert upper-third centering — inconsistent start positions across tabs
felt jumpy. Back to standard top-align with consistent table placement.

Demo GIF now pauses on Nodes tab (cluster view is a selling point) and
skips Maint/Users quickly instead of sprinting through all three.
2026-06-20 14:30:40 -04:00
lerko ef8e5c0b93 chore(assets): recapture screenshots with version and tab fixes
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m17s
CI / vulncheck (pull_request) Successful in 51s
All screenshots now show clean version string, "Monitors" tab label,
and upper-third vertical centering on sparse tabs.
2026-06-20 14:20:36 -04:00
lerko 94b27488bd fix(tui): vertically center sparse tab content
Tables on tabs with few rows (Alerts, Nodes, Maint, Users) now sit in
the upper third of the viewport instead of flush against the tab bar.
Dense tabs like Monitors and Logs fill naturally and are unaffected.
2026-06-20 14:11:18 -04:00
lerko 7d0b4dab8b fix(tui): clean pseudo-version in footer, rename Sites tab to Monitors
Strip Go module pseudo-version suffix (timestamp+hash+dirty) from the
footer version string — shows "v0.1.0" instead of the full build
metadata. Rename "Sites" tab and breadcrumbs to "Monitors" for
consistency with README, CLI help, and user-facing docs.
2026-06-20 14:03:48 -04:00
lerko d0d716b07a feat(dist): add GHCR push and Homebrew tap to release pipeline
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 51s
Docker releases now dual-push to Docker Hub and ghcr.io/lerkolabs/uptop.
GoReleaser brews section generates a Homebrew formula and pushes to
lerkolabs/homebrew-tap on GitHub.

Requires new Gitea secrets:
- GHCR_USERNAME / GHCR_TOKEN for GHCR login
- HOMEBREW_TAP_GITHUB_TOKEN for tap repo push
- GitHub repo lerkolabs/homebrew-tap must exist
2026-06-20 13:24:21 -04:00
lerko 9889ba4417 feat(readme): add logo, Go Report Card and release badges
SVG text mark using Flexoki Dark palette — teal arrow, monospace text,
green status dot. Added Go Report Card and latest release badges.
2026-06-20 13:22:16 -04:00
lerko c71d5b17f0 feat(assets): refresh screenshots, add demo GIF and theme montage
Re-captured all 6 TUI screenshots via VHS with realistic demo data.
Added animated demo GIF as README hero image and a 5-theme montage
strip showing Flexoki Dark, Tokyo Night, Catppuccin, Nord, Gruvbox.
2026-06-20 13:21:32 -04:00
lerko 5ca534b0b1 fix(github): compact jq output for GITHUB_OUTPUT compatibility
CI / test (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 51s
LABEL_IDS was pretty-printed multi-line JSON, which breaks
GITHUB_OUTPUT's single-line format. Also compact GH_LABELS
before passing as --argjson to avoid multi-line toJSON expansion.
2026-06-20 12:53:11 -04:00
lerko 70c12ca24b feat(github): accept issues on GitHub and auto-forward to Gitea
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
GitHub mirror previously redirected issue reporters to Gitea, which
requires login. Now GitHub Issues are accepted directly via form
templates (bug report + feature request) and a workflow forwards
new issues to Gitea with label mapping and provenance header.
2026-06-20 12:36:46 -04:00
lerko dbd519c121 fix: 4 additional release-consistency findings
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 51s
- Disable healthcheck on probe compose services (no HTTP server)
- Remove stale "(Phase 4)" comment from dev compose
- Add data/ to .gitignore (compose volume creates deploy/data)
- Clarify -db-type flag help text (sqlite or postgres)
2026-06-19 20:37:42 -04:00
lerko b32145fb58 fix: resolve 13 release-consistency findings
Documentation:
- Fix CI badge href to /actions (was 404 on Gitea)
- Add UPTOP_METRICS_PUBLIC + UPTOP_MAINT_RETENTION to README env table
- Link maintenance retention to env var name in data retention section
- Note metrics auth requirement in features list
- Fix clustering.md: fail-closed wording, mark AGG_STRATEGY/NODE_REGION optional
- Fix .env.example: wording (no .env loader), add TRUSTED_PROXIES + MAINT_RETENTION
- Add CLI help/usage with subcommand listing, accept serve/help/-h/-version

Docker/deploy:
- Add EXPOSE 8080 to Dockerfile
- Remove dead LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND env
- Exempt /api/health from cluster auth (fixes Docker HEALTHCHECK 401)
- Add sysctls for unprivileged ping to all compose files

Cosmetic:
- Fix bug_report.yaml: SemVer placeholder, remove nonexistent serve subcommand
2026-06-19 20:09:03 -04:00
lerko 47d3b0e68f fix(tui): bump Subtle ANSI fallback from "8" to "7"
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
Bright black ("8") plus Faint made PENDING status and dividers nearly
invisible in 16-color terminals. White ("7") with Faint renders as a
readable dim gray while still sitting below Muted in the hierarchy.
2026-06-19 17:27:54 -04:00
lerko 8fd13fefbf feat(tui): add monochrome emphasis attributes for SSH readability
Apply Bold/Faint attributes to semantic styles following htop's
monochrome design principle. Creates 4-tier visual hierarchy that
works even when colors collapse: Bold (danger/warn), Normal (success/
default), Faint (subtle/stale/borders/inactive tabs). Complements
the ANSI-16 color fallbacks without affecting TrueColor appearance.
2026-06-19 17:27:54 -04:00
lerko 974c4b61ea fix(tui): add ANSI-16 color fallbacks for SSH terminals
Theme colors now use lipgloss.CompleteColor with hand-picked ANSI-16
values instead of raw hex. Prevents algorithmic degradation from
collapsing dark backgrounds into indistinguishable ANSI colors over
SSH. Backgrounds fall through to terminal default in 16-color mode;
semantic colors map to distinct ANSI indices (green/yellow/red/blue/
cyan/magenta). TrueColor rendering is unchanged.
2026-06-19 17:27:54 -04:00
lerko d50a5159d4 fix(release): pin GoReleaser to triggering tag
CI / test (pull_request) Successful in 1m43s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
GORELEASER_CURRENT_TAG prevents GoReleaser from resolving the
wrong tag via git-describe when multiple tags point to the same
commit (e.g. v0.1.0 + v0.1.0-rc.5 on adf8fed).
2026-06-17 17:26:16 -04:00
93 changed files with 5754 additions and 3937 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
# ─── uptop configuration ───────────────────────────────────
# Copy to .env and edit. Only uncomment what you need.
# Export in your environment or pass via docker run --env-file.\n# Only uncomment what you need.
# ─── Core ──────────────────────────────────────────────────
UPTOP_PORT=23234 # SSH server port
@@ -40,3 +40,5 @@ UPTOP_DB_DSN=/data/uptop.db # File path (SQLite) or connection string (Postgre
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
# UPTOP_TRUSTED_PROXIES= # Comma-separated CIDRs/IPs for X-Forwarded-For trust
# UPTOP_MAINT_RETENTION=168h # How long ended maintenance windows are kept
+3 -3
View File
@@ -16,7 +16,7 @@ body:
label: What happened?
description: Include what you expected to happen instead.
placeholder: |
When I run `uptop serve`, the TUI crashes after 10 seconds.
When I run `uptop`, the TUI crashes after 10 seconds.
I expected it to keep running and display monitor status.
validations:
required: true
@@ -25,7 +25,7 @@ body:
attributes:
label: Steps to reproduce
placeholder: |
1. Run `uptop serve`
1. Run `uptop`
2. Wait ~10 seconds
3. TUI crashes with panic
validations:
@@ -37,7 +37,7 @@ body:
description: Output of `uptop version`, OS, terminal. Paste any errors below.
render: shell
placeholder: |
uptop version 2026.06.1
uptop 0.1.0 (abc1234, 2026-06-17)
OS: Debian 13
Terminal: Ghostty
+2
View File
@@ -49,9 +49,11 @@ jobs:
version: "~> v2"
args: release --clean --release-notes=/tmp/release-notes.md
env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_API_URL: http://gitea:3000/api/v1
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
# which runs on GitHub Actions when the push mirror delivers the tag and
+13 -1
View File
@@ -35,11 +35,16 @@ jobs:
TAGS="lerkolabs/uptop:${TAG}"
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:${TAG}"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:sha-${SHORT_SHA}"
# :latest only for real releases — rc rehearsal tags must not move it
if [ "${{ github.ref_type }}" = "tag" ]; then
case "$TAG" in
*-*) ;;
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;;
*)
TAGS="${TAGS},lerkolabs/uptop:latest"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:latest"
;;
esac
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
@@ -56,6 +61,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
# Scan must gate the push: build amd64 locally, scan it, and only then run
# the multi-arch push (amd64 layers come from the builder cache, so the
# second build only adds the arm64 work).
+46
View File
@@ -0,0 +1,46 @@
name: Bug Report
description: Something isn't working as expected
labels:
- bug
body:
- type: checkboxes
id: search
attributes:
label: Before filing
options:
- label: I searched existing issues and didn't find a match
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Include what you expected to happen instead.
placeholder: |
When I run `uptop`, the TUI crashes after 10 seconds.
I expected it to keep running and display monitor status.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
placeholder: |
1. Run `uptop`
2. Wait ~10 seconds
3. TUI crashes with panic
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment & logs
description: Output of `uptop version`, OS, terminal. Paste any errors below.
render: shell
placeholder: |
uptop 0.1.0 (abc1234, 2026-06-17)
OS: Debian 13
Terminal: Ghostty
[paste any error output here]
validations:
required: false
-7
View File
@@ -1,8 +1 @@
blank_issues_enabled: false
contact_links:
- name: Report a Bug
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=bug_report.yaml
about: Report bugs on our Gitea instance
- name: Request a Feature
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=feature_request.yaml
about: Suggest features on our Gitea instance
@@ -0,0 +1,20 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels:
- feature
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What's frustrating or missing?
placeholder: I find myself always needing to ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How would you like this to work?
validations:
required: false
+78
View File
@@ -0,0 +1,78 @@
name: Forward Issues to Gitea
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
forward:
runs-on: ubuntu-latest
steps:
- name: Build issue body
env:
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_URL: ${{ github.event.issue.html_url }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
jq -n \
--arg url "$ISSUE_URL" \
--arg author "$ISSUE_AUTHOR" \
--arg body "$ISSUE_BODY" \
'">" + " Forwarded from GitHub: " + $url + "\n> Reported by: [@" + $author + "](https://github.com/" + $author + ")\n\n---\n\n" + $body' \
-r > /tmp/gitea-issue-body.md || exit 1
- name: Resolve label IDs
id: labels
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GH_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
run: |
GITEA_LABELS=$(curl -f \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/labels?limit=50") || exit 1
GH_LABELS_COMPACT=$(echo "$GH_LABELS" | jq -c '.')
LABEL_IDS=$(echo "$GITEA_LABELS" | jq -c --argjson gh "$GH_LABELS_COMPACT" '
[.[] | select(
.name == "github" or
(.name as $n | $gh | index($n) != null)
) | .id]
')
echo "ids=${LABEL_IDS}" >> "$GITHUB_OUTPUT"
- name: Create Gitea issue
id: create
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
ISSUE_TITLE: ${{ github.event.issue.title }}
LABEL_IDS: ${{ steps.labels.outputs.ids }}
run: |
RESPONSE=$(curl -f -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/issues" \
-d "$(jq -n \
--arg title "$ISSUE_TITLE" \
--rawfile body /tmp/gitea-issue-body.md \
--argjson labels "$LABEL_IDS" \
'{title: $title, body: $body, labels: $labels}'
)") || exit 1
GITEA_URL=$(echo "$RESPONSE" | jq -re '.html_url') || exit 1
GITEA_NUM=$(echo "$RESPONSE" | jq -re '.number') || exit 1
echo "url=${GITEA_URL}" >> "$GITHUB_OUTPUT"
echo "number=${GITEA_NUM}" >> "$GITHUB_OUTPUT"
- name: Comment on GitHub issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_URL: ${{ steps.create.outputs.url }}
run: |
gh issue comment "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
--body "Thanks for reporting! This issue has been forwarded to our [primary tracker](${GITEA_URL}). Discussion and updates will happen there."
+2
View File
@@ -25,4 +25,6 @@ authorized_keys
tmp
*.local.json
*.local.md
data/
.env
vhs
+18
View File
@@ -59,6 +59,24 @@ nfpms:
dst: /usr/share/doc/uptop/LICENSE
type: doc
brews:
- name: uptop
repository:
owner: lerkolabs
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
url_template: "https://github.com/lerkolabs/uptop/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
commit_author:
name: lerkolabs-bot
email: bot@lerkolabs.com
homepage: https://github.com/lerkolabs/uptop
description: Self-hosted uptime monitoring with a TUI over SSH
license: MIT
install: |
bin.install "uptop"
test: |
system bin/"uptop", "version"
# Changelog generation must stay enabled: the --release-notes flag is consumed
# by the changelog pipe, so disabling it silently drops the git-cliff notes
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
+1 -2
View File
@@ -23,14 +23,13 @@ RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
COPY --from=builder /app/uptop .
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
ENV UPTOP_DB_TYPE=sqlite
ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPTOP_KEYS=/data/authorized_keys
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
ENV UPTOP_PORT=23234
EXPOSE 23234
EXPOSE 8080 23234
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/health || exit 1
USER uptop
+25 -12
View File
@@ -1,16 +1,18 @@
<div align="center">
<h1>uptop</h1>
<img src="assets/logo.svg" alt="uptop" width="320">
<p>Self-hosted uptime monitoring with a TUI over SSH.</p>
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
<p>
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/lerkolabs/uptop/releases/latest"><img src="https://img.shields.io/github/v/release/lerkolabs/uptop" alt="Latest Release"></a>
<a href="https://goreportcard.com/report/github.com/lerkolabs/uptop"><img src="https://goreportcard.com/badge/github.com/lerkolabs/uptop" alt="Go Report Card"></a>
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
</p>
<img src="assets/monitors.png" alt="uptop monitors view" width="800">
<img src="assets/demo.gif" alt="uptop demo" width="800">
</div>
## What is this
@@ -27,7 +29,7 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
- **HA clustering** — leader/follower with automatic failover
- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana
- **Prometheus metrics** — `/metrics` endpoint (`UPTOP_METRICS_PUBLIC=true` to expose without auth)
- **Public status page** — HTML + JSON, toggle with an env var
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
- **Uptime Kuma import** — migrate from Kuma with one command
@@ -38,18 +40,27 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
<table>
<tr>
<td><img src="assets/detail.png" alt="detail panel" width="400"></td>
<td><img src="assets/alerts.png" alt="alerts view" width="400"></td>
<td><img src="assets/monitors.png" alt="monitors + log sidebar" width="400"></td>
<td><img src="assets/detail.png" alt="inline detail panel" width="400"></td>
</tr>
<tr>
<td><img src="assets/logs.png" alt="logs view" width="400"></td>
<td><img src="assets/fulldetail.png" alt="full-screen detail" width="400"></td>
<td><img src="assets/settings.png" alt="settings / alerts" width="400"></td>
</tr>
<tr>
<td><img src="assets/maint.png" alt="maintenance windows" width="400"></td>
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="assets/theme.png" alt="theme selection" width="600"></td>
</tr>
</table>
### Themes
Five built-in themes: Flexoki Dark, Tokyo Night, Catppuccin Mocha, Nord, Gruvbox. Press `T` to cycle.
<p align="center">
<img src="assets/themes.png" alt="all five themes" width="800">
</p>
## Quick start
```bash
@@ -146,6 +157,8 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
| `UPTOP_METRICS_PUBLIC` | `false` | Expose `/metrics` without auth |
| `UPTOP_MAINT_RETENTION` | `168h` | How long ended maintenance windows are kept |
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
@@ -179,7 +192,7 @@ uptop prunes its own history in the background — no external cleanup jobs need
| Check history | newest 1,000 checks per monitor |
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
| Logs | newest 200 entries |
| Maintenance windows | 7 days after they end (configurable) |
| Maintenance windows | 7 days after they end (`UPTOP_MAINT_RETENTION`) |
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" fill="none">
<!-- Terminal-inspired text mark for uptop -->
<rect width="320" height="80" rx="8" fill="#1C1B1A"/>
<!-- Prompt caret -->
<text x="16" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#3AA99F"></text>
<!-- "uptop" in monospace -->
<text x="52" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#CECDC3">uptop</text>
<!-- Status dot -->
<circle cx="296" cy="40" r="6" fill="#879A39"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 312 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+8 -8
View File
@@ -43,10 +43,10 @@ func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
_, unknown := testKey(t)
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
if !kc.IsAllowed(known) {
if !kc.IsAllowed(context.Background(), known) {
t.Error("known key denied")
}
if kc.IsAllowed(unknown) {
if kc.IsAllowed(context.Background(), unknown) {
t.Error("unknown key allowed")
}
}
@@ -56,7 +56,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(known) {
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
@@ -67,7 +67,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
kc.updated = time.Now().Add(-time.Hour)
kc.mu.Unlock()
if !kc.IsAllowed(known) {
if !kc.IsAllowed(context.Background(), known) {
t.Error("transient refresh error locked out a previously valid key")
}
}
@@ -77,7 +77,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(known) {
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
@@ -86,7 +86,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
ms.err = errors.New("db down")
kc.Invalidate()
if kc.IsAllowed(known) {
if kc.IsAllowed(context.Background(), known) {
t.Error("revoked key still allowed while DB is down — fails open")
}
}
@@ -97,7 +97,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
kc := newKeyCache(ms)
s := &userInvalidatingStore{Store: ms, kc: kc}
if !kc.IsAllowed(known) {
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
@@ -109,7 +109,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
ms.users = nil
ms.err = errors.New("db down")
if kc.IsAllowed(known) {
if kc.IsAllowed(context.Background(), known) {
t.Error("deleted user's key still allowed from stale cache")
}
}
+66 -36
View File
@@ -52,7 +52,14 @@ func init() {
return
}
if mv := info.Main.Version; mv != "" && mv != "(devel)" {
version = strings.TrimPrefix(mv, "v")
mv = strings.TrimPrefix(mv, "v")
// Pseudo-versions (e.g. "0.1.1-0.20260620165311-5ca534b0b100+dirty")
// are noisy in the TUI footer. Extract just the base semver.
if i := strings.Index(mv, "-0."); i > 0 {
mv = mv[:i]
}
mv = strings.TrimSuffix(mv, "+dirty")
version = mv
}
for _, s := range info.Settings {
switch s.Key {
@@ -77,17 +84,37 @@ func main() {
case "export":
runExport(os.Args[2:])
return
case "version", "--version", "-v":
case "version", "--version", "-v", "-version":
printVersion()
return
case "migrate-secrets":
runMigrateSecrets(os.Args[2:])
return
case "help", "--help", "-h":
printUsage()
return
case "serve":
runServe(os.Args[2:])
return
}
}
runServe(os.Args[1:])
}
func printUsage() {
fmt.Fprintf(os.Stderr, `Usage: uptop <command> [flags]
Commands:
serve Start the server (default if no command given)
apply Apply monitors from a YAML file
export Export monitors to YAML
migrate-secrets Re-encrypt alert credentials with current key
version Print version and exit
Run 'uptop serve --help' for server flags.
`)
}
func printVersion() {
out := "uptop " + version
var meta []string
@@ -152,7 +179,7 @@ func parseTrustedProxies(raw string) []*net.IPNet {
return cidrs
}
func openStore(dbType, dsn string) store.Store {
func openStore(ctx context.Context, dbType, dsn string) store.Store {
var ss *store.SQLStore
var err error
if dbType == "postgres" {
@@ -174,7 +201,7 @@ func openStore(dbType, dsn string) store.Store {
} else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
}
if err := ss.Init(context.Background()); err != nil {
if err := ss.Init(ctx); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
@@ -186,7 +213,7 @@ func runApply(args []string) {
filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
@@ -196,7 +223,8 @@ func runApply(args []string) {
os.Exit(1)
}
s := openStore(*dbType, *dsn)
ctx := context.Background()
s := openStore(ctx, *dbType, *dsn)
f, err := config.LoadFile(*filePath)
if err != nil {
@@ -204,7 +232,7 @@ func runApply(args []string) {
os.Exit(1)
}
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
DryRun: *dryRun,
Prune: *prune,
})
@@ -219,13 +247,14 @@ func runApply(args []string) {
func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn)
ctx := context.Background()
s := openStore(ctx, *dbType, *dsn)
f, err := config.Export(context.Background(), s)
f, err := config.Export(ctx, s)
if err != nil {
slog.Error("export failed", "err", err)
os.Exit(1)
@@ -239,7 +268,7 @@ func runExport(args []string) {
func runMigrateSecrets(args []string) {
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args)
@@ -254,6 +283,8 @@ func runMigrateSecrets(args []string) {
os.Exit(1)
}
ctx := context.Background()
var ss *store.SQLStore
if *dbType == "postgres" {
ss, err = store.NewPostgresStore(*dsn)
@@ -264,21 +295,21 @@ func runMigrateSecrets(args []string) {
slog.Error("database connection failed", "err", err)
os.Exit(1)
}
if err := ss.Init(context.Background()); err != nil {
if err := ss.Init(ctx); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
ss.SetEncryptor(enc)
alerts, err := ss.GetAllAlerts(context.Background())
alerts, err := ss.GetAllAlerts(ctx)
if err != nil {
slog.Error("failed to load alerts", "err", err)
os.Exit(1)
}
migrated := 0
for _, a := range alerts {
if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil {
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil {
slog.Error("alert migration failed", "alert", a.Name, "err", err)
os.Exit(1)
}
@@ -331,7 +362,7 @@ func runServe(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", cfg.Port, "SSH Port")
flagDBType := fs.String("db-type", cfg.DBType, "Database type")
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)")
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
@@ -365,15 +396,19 @@ func runServe(args []string) {
kc := newKeyCache(ss)
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
if err := s.Init(context.Background()); err != nil {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Init(ctx); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1)
}
if *demo {
seedDemoData(s)
seedDemoData(ctx, s)
}
seedKeysFromEnv(s)
seedKeysFromEnv(ctx, s)
if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma)
@@ -382,7 +417,7 @@ func runServe(args []string) {
os.Exit(1)
}
backup := importer.ConvertKuma(kb)
if err := s.ImportData(context.Background(), backup); err != nil {
if err := s.ImportData(ctx, backup); err != nil {
slog.Error("import failed", "err", err)
os.Exit(1)
}
@@ -402,12 +437,9 @@ func runServe(args []string) {
}
eng.SetMaintRetention(cfg.MaintRetention)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng.InitHistory()
eng.InitLogs()
eng.InitAlertHealth()
eng.InitHistory(ctx)
eng.InitLogs(ctx)
eng.InitAlertHealth(ctx)
eng.Start(ctx)
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
@@ -423,7 +455,7 @@ func runServe(args []string) {
sshSrv := startSSHServer(*port, s, eng, kc)
if localTUI {
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
slog.Error("TUI failed", "err", err)
}
@@ -457,11 +489,11 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return kc.IsAllowed(key)
return kc.IsAllowed(ctx, key)
}),
wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}),
),
)
@@ -477,8 +509,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
return s
}
func seedDemoData(s store.Store) {
ctx := context.Background()
func seedDemoData(ctx context.Context, s store.Store) {
existing, _ := s.GetSites(ctx)
if len(existing) > 0 {
return
@@ -539,8 +570,8 @@ func newKeyCache(db store.Store) *keyCache {
return &keyCache{db: db, ttl: 30 * time.Second}
}
func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers(context.Background())
func (c *keyCache) refresh(ctx context.Context) {
users, err := c.db.GetAllUsers(ctx)
if err != nil {
// Keep the previous key set: a transient DB error must not lock every
// admin out. Revocation still fails closed because Invalidate clears
@@ -573,13 +604,13 @@ func (c *keyCache) Invalidate() {
c.mu.Unlock()
}
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool {
c.mu.RLock()
stale := time.Since(c.updated) > c.ttl
c.mu.RUnlock()
if stale {
c.refresh()
c.refresh(ctx)
}
c.mu.RLock()
@@ -625,8 +656,7 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back
return err
}
func seedKeysFromEnv(s store.Store) {
ctx := context.Background()
func seedKeysFromEnv(ctx context.Context, s store.Store) {
var keys []string
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
+4
View File
@@ -5,6 +5,8 @@ services:
leader:
image: lerkolabs/uptop:latest
container_name: uptop-leader
sysctls:
- net.ipv4.ping_group_range=0 2147483647
ports:
- "23234:23234" # SSH
- "8080:8080" # HTTP
@@ -40,6 +42,8 @@ services:
follower:
image: lerkolabs/uptop:latest
container_name: uptop-follower
sysctls:
- net.ipv4.ping_group_range=0 2147483647
ports:
- "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (Mapped to different host port)
+1 -1
View File
@@ -13,7 +13,7 @@ services:
- UPTOP_DB_TYPE=postgres
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
# --- Web Server Configuration (Phase 4) ---
# --- Web Server Configuration ---
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
+6
View File
@@ -1,6 +1,8 @@
services:
leader:
image: lerkolabs/uptop:latest
sysctls:
- net.ipv4.ping_group_range=0 2147483647
environment:
- UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
@@ -12,6 +14,8 @@ services:
probe-us-east:
image: lerkolabs/uptop:latest
healthcheck:
disable: true
environment:
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=us-east-1
@@ -24,6 +28,8 @@ services:
probe-eu-west:
image: lerkolabs/uptop:latest
healthcheck:
disable: true
environment:
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=eu-west-1
+2
View File
@@ -8,6 +8,8 @@ services:
- ALL
security_opt:
- no-new-privileges:true
sysctls:
- net.ipv4.ping_group_range=0 2147483647
tmpfs:
- /tmp
ports:
+2 -4
View File
@@ -47,13 +47,11 @@ Probes are lightweight, stateless nodes that run checks from different locations
| Node | Variable | Value |
|------|----------|-------|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
| Leader | `UPTOP_AGG_STRATEGY` | `any-down`, `majority-down`, or `all-down` |
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
| Probe | `UPTOP_NODE_REGION` | Region tag matching monitor assignments |
Optional: `UPTOP_NODE_NAME` for a human-readable label in the TUI.
Optional: `UPTOP_AGG_STRATEGY` (default `any-down`), `UPTOP_NODE_REGION` (omit to match all monitors), `UPTOP_NODE_NAME` (human-readable label in the TUI).
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
@@ -80,6 +78,6 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
## Security
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated.
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints reject all requests (fail closed); only `/api/health` stays open.
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
+3 -1
View File
@@ -18,7 +18,9 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
var alertClient = &http.Client{Timeout: 10 * time.Second}
const alertHTTPTimeout = 10 * time.Second
var alertClient = &http.Client{Timeout: alertHTTPTimeout}
// sanitizeError strips the request URL from transport errors before they are
// stored or displayed. *url.Error embeds the full URL, which for several
+10 -4
View File
@@ -38,14 +38,20 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
// "probe" mode is handled directly in main.go before cluster.Start is called
}
const (
followerTimeout = 2 * time.Second
leaderFailureThreshold = 3
followerRetryInterval = 5 * time.Second
)
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
client := http.Client{Timeout: 2 * time.Second}
client := http.Client{Timeout: followerTimeout}
failures := 0
threshold := 3
threshold := leaderFailureThreshold
for {
select {
case <-time.After(5 * time.Second):
case <-time.After(followerRetryInterval):
case <-ctx.Done():
return
}
@@ -59,7 +65,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
isLeaderHealthy := false
if err == nil {
isLeaderHealthy = resp.StatusCode == 200
isLeaderHealthy = resp.StatusCode == http.StatusOK
_ = resp.Body.Close()
}
+9 -3
View File
@@ -26,12 +26,18 @@ type ProbeConfig struct {
AllowPrivateTargets bool
}
const (
probeMinInterval = 10
probeDefaultInterval = 30
probeAPITimeout = 10 * time.Second
)
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if cfg.Interval < 10 {
cfg.Interval = 30
if cfg.Interval < probeMinInterval {
cfg.Interval = probeDefaultInterval
}
apiClient := &http.Client{Timeout: 10 * time.Second}
apiClient := &http.Client{Timeout: probeAPITimeout}
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
strictClient := &http.Client{
Transport: &http.Transport{
+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 {
+272
View File
@@ -0,0 +1,272 @@
package monitor
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type AlertHealth struct {
LastSendAt time.Time
LastSendOK bool
LastError string
SendCount int
FailCount int
}
// InitAlertHealth restores persisted alert send health so the dashboard shows real
// "last sent" / health state on startup instead of resetting every channel to "never".
func (e *Engine) InitAlertHealth(ctx context.Context) {
records, err := e.db.LoadAlertHealth(ctx)
if err != nil {
return
}
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
for id, r := range records {
e.alertHealth[id] = AlertHealth{
LastSendAt: r.LastSendAt,
LastSendOK: r.LastSendOK,
LastError: r.LastError,
SendCount: r.SendCount,
FailCount: r.FailCount,
}
}
}
// handleStatusChange folds a check result into the live state. snap is the
// stale snapshot the check ran against; the actual mutation is applied onto the
// CURRENT live entry via applyState, so a concurrent pause / config edit /
// heartbeat is never reverted by this write. Logs and alerts are emitted after
// the lock is released, off the critical section.
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
if !e.IsActive() {
return
}
inMaint := e.isInMaintenance(snap.ID)
status := models.Status(rawStatus)
var (
prev, next models.Status
name, typ string
alertID int
failCount, maxRetries int
confirmedDown bool
failedCheck bool
downSince time.Time
sslWarnFire bool
sslDays int
skipped bool
changed bool
)
_, exists := e.applyState(snap.ID, func(s *models.Site) {
// A non-UP result computed from a stale snapshot must not override a
// heartbeat (or newer check) that landed while we were evaluating.
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
skipped = true
return
}
prev = s.Status
name = s.Name
typ = s.Type
alertID = s.AlertID
maxRetries = s.MaxRetries
downSince = s.StatusChangedAt
// Fresh check results (measured by the run against snap).
s.StatusCode = code
s.Latency = snap.Latency
s.LastCheck = snap.LastCheck
s.HasSSL = snap.HasSSL
s.CertExpiry = snap.CertExpiry
s.LastError = errorReason
if status == models.StatusUp {
s.LastSuccessAt = time.Now()
s.LastError = ""
}
// Status + failure-count transition, based on the CURRENT live status.
if status == models.StatusUp {
s.FailureCount = 0
s.Status = models.StatusUp
} else {
if s.FailureCount <= s.MaxRetries {
s.FailureCount++
}
if s.FailureCount > s.MaxRetries {
if s.Status != status {
confirmedDown = true
}
s.Status = status
s.FailureCount = s.MaxRetries + 1
} else {
failedCheck = true
}
}
failCount = s.FailureCount
if s.Status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
s.StatusChangedAt = time.Now()
}
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
if typ == "http" && s.CheckSSL && s.HasSSL {
days := int(time.Until(s.CertExpiry).Hours() / 24)
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
sslWarnFire = true
sslDays = days
s.SentSSLWarning = true
} else if days > s.ExpiryThreshold {
s.SentSSLWarning = false
}
}
next = s.Status
changed = next != prev
})
if !exists || skipped {
return
}
e.recordCheck(snap.ID, latency, status == models.StatusUp)
if confirmedDown {
if errorReason != "" {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
}
} else if failedCheck {
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
}
if changed && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
}
if sslWarnFire {
if !inMaint {
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
} else {
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
}
}
if prev == models.StatusUp && next == models.StatusLate {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
}
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
} else {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
if errorReason != "" {
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
}
if typ == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
}
e.triggerAlert(alertID, "🚨 ALERT", msg)
}
}
if prev.IsBroken() && next == models.StatusUp {
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
if !inMaint {
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
}
}
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
}
}
func (e *Engine) triggerAlert(alertID int, title, message string) {
if alertID <= 0 {
return
}
cfg, err := e.db.GetAlert(e.ctx, alertID)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return
}
provider := alert.GetProvider(cfg)
if provider != nil {
go func() {
ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout)
defer cancel()
if err := provider.Send(ctx, title, message); err != nil {
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
e.recordAlertResult(alertID, false, err.Error())
} else {
e.recordAlertResult(alertID, true, "")
}
}()
}
}
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
h := e.alertHealth[alertID]
h.LastSendAt = time.Now()
h.LastSendOK = ok
h.SendCount++
if ok {
h.LastError = ""
} else {
h.LastError = errMsg
h.FailCount++
}
e.alertHealth[alertID] = h
// Persist so health survives restarts; DB IO off the alert path.
e.enqueueWrite(writeAlertHealth{rec: models.AlertHealthRecord{
AlertID: alertID,
LastSendAt: h.LastSendAt,
LastSendOK: h.LastSendOK,
LastError: h.LastError,
SendCount: h.SendCount,
FailCount: h.FailCount,
}})
}
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
e.alertHealthMu.RLock()
defer e.alertHealthMu.RUnlock()
return e.alertHealth[alertID]
}
func (e *Engine) TestAlert(ctx context.Context, alertID int) error {
cfg, err := e.db.GetAlert(ctx, alertID)
if err != nil {
return fmt.Errorf("failed to load alert: %w", err)
}
provider := alert.GetProvider(cfg)
if provider == nil {
return fmt.Errorf("no provider for type %q", cfg.Type)
}
ctx, cancel := context.WithTimeout(ctx, alertSendTimeout)
defer cancel()
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
if err != nil {
e.recordAlertResult(alertID, false, err.Error())
return err
}
e.recordAlertResult(alertID, true, "")
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
return nil
}
+487
View File
@@ -0,0 +1,487 @@
package monitor
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// --- Group 1: State Machine ---
func TestHandleStatusChange_PendingToUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert for PENDING→UP")
}
}
func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (under retry threshold), got %s", s.Status)
}
if s.FailureCount != 1 {
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
}
}
func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 2},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
if s.FailureCount != 3 {
t.Errorf("expected FailureCount 3, got %d", s.FailureCount)
}
waitAsync()
calls := ms.getAlertCallsSnapshot()
if len(calls) == 0 || calls[0] != 1 {
t.Errorf("expected alert call for alertID 1, got %v", calls)
}
}
func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected alert on immediate DOWN")
}
}
func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 4},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected recovery alert")
}
}
func TestHandleStatusChange_DownStaysDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no re-alert for already DOWN")
}
}
func TestHandleStatusChange_SSLExpired(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
s, _ := getSite(e, 1)
if s.Status != "SSL EXP" {
t.Errorf("expected SSL EXP, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected alert on SSL EXP")
}
}
func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance")
}
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l.Message, "suppressed") {
found = true
break
}
}
if !found {
t.Error("expected log mentioning suppressed")
}
}
func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance recovery")
}
}
func TestHandleStatusChange_SSLWarning(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if !s.SentSSLWarning {
t.Error("expected SentSSLWarning=true")
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected SSL warning alert")
}
}
func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no repeat SSL warning")
}
}
func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if s.SentSSLWarning {
t.Error("expected SentSSLWarning reset to false")
}
}
func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if !s.SentSSLWarning {
t.Error("expected SentSSLWarning=true even in maintenance")
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance")
}
}
func TestHandleStatusChange_InactiveEngine(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.SetActive(false)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Error("expected no state change when inactive")
}
}
// --- Group 10: liveState merge (lost-update race) ---
// A pause that lands while a check is in flight must survive the check's
// write-back. The old code snapshotted the site, ran the check, then wrote the
// whole stale struct back — reverting the pause.
func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
// `site` is the stale snapshot the check ran against (Paused=false).
// Meanwhile the user pauses the monitor.
e.ToggleSitePause(1)
// Check completes and folds its result in using the stale snapshot.
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
s, _ := getSite(e, 1)
if !s.Paused {
t.Error("pause was reverted by a stale check write-back")
}
if s.Status != "DOWN" {
t.Errorf("expected check result still applied (DOWN), got %s", s.Status)
}
}
// A config edit that lands while a check is in flight must survive; the check
// must not resurrect the old config from its snapshot.
func TestHandleStatusChange_ConfigEditDuringCheckSurvives(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com", Type: "http", MaxRetries: 0, Interval: 30},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
// Config changes mid-check.
e.UpdateSiteConfig(models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60})
// Stale check (ran against http://old.com) folds its result in.
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.URL != "http://new.com" {
t.Errorf("config edit reverted: URL=%s", s.URL)
}
if s.Interval != 60 {
t.Errorf("config edit reverted: Interval=%d", s.Interval)
}
}
// The classic push false-DOWN: a heartbeat marks the monitor UP while a
// staleness evaluation (computed from the older LastCheck) is mid-flight.
// The stale DOWN must not overwrite the fresh heartbeat.
func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
// Snapshot the engine would have taken before evaluating staleness:
// LastCheck is old, so checkPush decided "DOWN".
snap := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Token: "tok", Interval: 10},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
}
injectSite(e, snap)
// A heartbeat lands first, advancing LastCheck and confirming UP.
if !e.RecordHeartbeat("tok") {
t.Fatal("heartbeat rejected")
}
// Now the in-flight stale evaluation tries to write DOWN.
e.handleStatusChange(snap, "DOWN", 0, 0, "heartbeat missed")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("stale DOWN overwrote a fresh heartbeat: status=%s", s.Status)
}
}
// A check result for a site removed mid-check must be dropped, not recreate it.
func TestHandleStatusChange_RemovedSiteDropped(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.RemoveSite(1)
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
if _, ok := getSite(e, 1); ok {
t.Error("removed site was recreated by a late check write-back")
}
}
// --- Group 12: Phase 3 engine correctness ---
// PENDING→DOWN must honor MaxRetries instead of alerting on first failure.
func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "new-monitor", MaxRetries: 2},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "timeout")
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING during retry, got %s", s.Status)
}
if s.FailureCount != 1 {
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
}
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
s, _ = getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING during retry 2, got %s", s.Status)
}
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
s, _ = getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
}
}
// LATE→DOWN must also honor MaxRetries.
func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-mon", MaxRetries: 1},
SiteState: models.SiteState{Status: "LATE"},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "missed heartbeat")
s, _ := getSite(e, 1)
if s.Status != "LATE" {
t.Errorf("expected LATE during retry, got %s", s.Status)
}
e.handleStatusChange(s, "DOWN", 0, 0, "missed heartbeat")
s, _ = getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
}
}
+40 -10
View File
@@ -130,8 +130,14 @@ func TestRunCheck_Port_Open(t *testing.T) {
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, true)
@@ -149,8 +155,14 @@ func TestRunCheck_Port_Closed(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
ln.Close()
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
@@ -168,8 +180,14 @@ func TestRunPortCheck_UsesPinnedIP(t *testing.T) {
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
// Pass a pinned IP — runPortCheck should dial it instead of resolving Hostname.
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "will-not-resolve.invalid", Port: port, Timeout: 2}
@@ -187,8 +205,14 @@ func TestRunPortCheck_NilPinnedIP_UsesHostname(t *testing.T) {
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := runPortCheck(context.Background(), site, nil)
@@ -205,8 +229,14 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, false)
+329
View File
@@ -0,0 +1,329 @@
package monitor
import (
"context"
"fmt"
"math/rand/v2"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (e *Engine) RecordHeartbeat(token string) bool {
if !e.IsActive() {
return false
}
e.mu.RLock()
targetID, ok := e.tokenIndex[token]
e.mu.RUnlock()
if !ok {
return false
}
var (
prevStatus models.Status
name string
alertID int
downSince time.Time
)
_, exists := e.applyState(targetID, func(s *models.Site) {
prevStatus = s.Status
name = s.Name
alertID = s.AlertID
downSince = s.StatusChangedAt // captured before mutation = when it went down
s.LastCheck = time.Now()
s.Status = models.StatusUp
s.FailureCount = 0
s.Latency = 0
s.LastError = ""
s.LastSuccessAt = time.Now()
if prevStatus != models.StatusUp {
s.StatusChangedAt = time.Now()
}
})
if !exists {
return false
}
switch prevStatus {
case models.StatusPending:
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
case models.StatusLate:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
case models.StatusStale:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
case models.StatusDown:
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
}
e.recordCheck(targetID, 0, true)
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
}
return true
}
func (e *Engine) getRecheckChan(id int) chan struct{} {
e.recheckMu.Lock()
defer e.recheckMu.Unlock()
ch, ok := e.recheck[id]
if !ok {
ch = make(chan struct{}, 1)
e.recheck[id] = ch
}
return ch
}
func (e *Engine) signalRecheck(id int) {
ch := e.getRecheckChan(id)
select {
case ch <- struct{}{}:
default:
}
}
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
recheckCh := e.getRecheckChan(id)
// Stagger initial check to avoid thundering herd on startup
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(stagger):
case <-ctx.Done():
return
}
e.checkByID(ctx, id)
for {
select {
case <-ctx.Done():
return
default:
}
if !e.IsActive() {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists {
return
}
if site.Paused {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
interval := site.Interval
if interval < minCheckInterval {
interval = minCheckInterval
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done():
return
case <-recheckCh:
}
e.checkByID(ctx, id)
}
}
func (e *Engine) checkByID(ctx context.Context, id int) {
if !e.IsActive() {
return
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists || site.Paused {
return
}
switch site.Type {
case "push":
e.checkPush(ctx, site)
case "group":
e.checkGroup(ctx, site)
default:
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
updatedSite.Latency = time.Duration(result.LatencyNs)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
}
}
func (e *Engine) checkPush(_ context.Context, site models.Site) {
if site.Status == models.StatusPending {
return
}
interval := time.Duration(site.Interval) * time.Second
grace := interval / 2
if grace < minPushGrace {
grace = minPushGrace
}
overdue := site.LastCheck.Add(interval)
staleMark := overdue.Add(grace / 2)
graceEnd := overdue.Add(grace)
now := time.Now()
if now.After(graceEnd) {
if site.Status != models.StatusDown {
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
}
} else if now.After(staleMark) {
if site.Status != models.StatusStale {
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
}
} else if now.After(overdue) {
if site.Status != models.StatusLate {
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
}
}
}
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
e.mu.RLock()
status := models.StatusUp
hasChildren := false
for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
}
hasChildren = true
if child.Paused || e.isInMaintenance(child.ID) {
continue
}
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
status = models.StatusDown
} else if child.Status == models.StatusStale && status != models.StatusDown {
status = models.StatusStale
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
status = models.StatusLate
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
status = models.StatusPending
}
}
e.mu.RUnlock()
if !hasChildren {
status = models.StatusPending
}
var prev models.Status
e.applyState(site.ID, func(s *models.Site) {
prev = s.Status
s.Status = status
if status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
}
})
e.recordCheck(site.ID, 0, !status.IsBroken())
if status != prev && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: site.ID, fromStatus: string(prev), toStatus: string(status)})
if status.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' is %s", site.Name, status))
} else if prev.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' recovered", site.Name))
}
}
}
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
}
// SetAggStrategy must be called before Start: the field is read by the probe
// aggregation path without synchronization.
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
e.aggStrategy = strategy
}
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
e.mu.RLock()
site, exists := e.liveState[siteID]
e.mu.RUnlock()
if !exists {
return
}
staleAfter := time.Duration(site.Interval) * time.Second * 3
if staleAfter < time.Minute {
staleAfter = time.Minute
}
now := time.Now()
e.probeResultsMu.Lock()
if e.probeResults[siteID] == nil {
e.probeResults[siteID] = make(map[string]NodeResult)
}
e.probeResults[siteID][nodeID] = NodeResult{
NodeID: nodeID,
IsUp: isUp,
LatencyNs: latencyNs,
CheckedAt: now,
ErrorReason: errorReason,
}
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
for id, r := range e.probeResults[siteID] {
if now.Sub(r.CheckedAt) > staleAfter {
delete(e.probeResults[siteID], id)
continue
}
results = append(results, r)
}
e.probeResultsMu.Unlock()
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
probeStatus := models.StatusUp
if !aggUp {
probeStatus = models.StatusDown
}
updatedSite := site
updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
}
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
src := e.probeResults[siteID]
cp := make(map[string]NodeResult, len(src))
for k, v := range src {
cp[k] = v
}
return cp
}
+389
View File
@@ -0,0 +1,389 @@
package monitor
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// --- Group 2: Heartbeat ---
func TestRecordHeartbeat_ValidToken(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
if !e.RecordHeartbeat("abc123") {
t.Error("expected true for valid token")
}
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if time.Since(s.LastCheck) > time.Second {
t.Error("expected LastCheck to be recent")
}
}
func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site)
if !e.RecordHeartbeat("abc123") {
t.Error("expected true")
}
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected recovery alert")
}
}
func TestRecordHeartbeat_UnknownToken(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
if e.RecordHeartbeat("unknown") {
t.Error("expected false for unknown token")
}
}
func TestRecordHeartbeat_InactiveEngine(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.SetActive(false)
if e.RecordHeartbeat("abc123") {
t.Error("expected false when inactive")
}
}
// --- Group 3: Push Deadline ---
func TestCheckPush_DeadlineMissed(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after missed deadline, got %s", s.Status)
}
}
func TestCheckPush_OverdueBecomesLate(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "LATE" {
t.Errorf("expected LATE when overdue but within grace, got %s", s.Status)
}
}
func TestCheckPush_OverdueBecomesStale(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
// interval=300, grace=150 (300/2), staleMark=overdue+75
// at 380s: past staleMark(375) but before graceEnd(450)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-380 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "STALE" {
t.Errorf("expected STALE when past midpoint of grace, got %s", s.Status)
}
}
func TestCheckPush_WithinDeadline(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
}
func TestCheckPush_PendingStaysPending(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING to stay until first heartbeat, got %s", s.Status)
}
}
// --- Group 4: Group Checks ---
func TestCheckGroup_AllChildrenUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "PENDING"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected group UP, got %s", s.Status)
}
}
func TestCheckGroup_OneChildDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected group DOWN, got %s", s.Status)
}
}
func TestCheckGroup_PausedChildIgnored(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (paused child ignored), got %s", s.Status)
}
}
func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
ms := newMockStore()
ms.maintenance[3] = true
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.refreshMaintenanceCache(context.Background())
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (maint child ignored), got %s", s.Status)
}
}
func TestCheckGroup_NoChildren(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING for no children, got %s", s.Status)
}
}
// Groups must not auto-pause when all children are paused — that creates a
// one-way trap because monitorRoutine skips paused sites.
func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Paused {
t.Error("group must not auto-pause when all children are paused")
}
}
// Dead probe results must be expired so they don't poison aggregation.
func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", Interval: 30},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.probeResultsMu.Lock()
e.probeResults[1] = map[string]NodeResult{
"dead-probe": {
NodeID: "dead-probe",
IsUp: false,
CheckedAt: time.Now().Add(-10 * time.Minute),
},
}
e.probeResultsMu.Unlock()
e.IngestProbeResult("live-probe", 1, 5000, true, "")
e.probeResultsMu.RLock()
_, deadExists := e.probeResults[1]["dead-probe"]
_, liveExists := e.probeResults[1]["live-probe"]
e.probeResultsMu.RUnlock()
if deadExists {
t.Error("stale probe result should have been expired")
}
if !liveExists {
t.Error("live probe result should still exist")
}
}
// RemoveSite must clean up probeResults.
func TestRemoveSite_CleansProbeResults(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.probeResultsMu.Lock()
e.probeResults[1] = map[string]NodeResult{
"node-a": {NodeID: "node-a", IsUp: true, CheckedAt: time.Now()},
}
e.probeResultsMu.Unlock()
e.RemoveSite(1)
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
if _, exists := e.probeResults[1]; exists {
t.Error("probe results should be cleaned up after RemoveSite")
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ type SiteHistory struct {
UpChecks int
}
func (e *Engine) InitHistory() {
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen)
func (e *Engine) InitHistory(ctx context.Context) {
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
if err != nil {
e.AddLog("Failed to load check history: " + err.Error())
return
+70
View File
@@ -0,0 +1,70 @@
package monitor
import (
"context"
"fmt"
"time"
)
func (e *Engine) maintenancePruner(ctx context.Context) {
ticker := time.NewTicker(maintPruneInterval)
defer ticker.Stop()
e.pruneMaintenanceWindows(ctx)
for {
select {
case <-ticker.C:
e.pruneMaintenanceWindows(ctx)
case <-ctx.Done():
return
}
}
}
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
if err != nil {
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
return
}
if pruned > 0 {
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
}
}
func (e *Engine) isInMaintenance(monitorID int) bool {
e.maintCacheMu.RLock()
defer e.maintCacheMu.RUnlock()
return e.maintCache[monitorID]
}
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
if err != nil {
return
}
directMaint := make(map[int]bool)
var globalMaint bool
for _, w := range windows {
if w.MonitorID == 0 {
globalMaint = true
} else {
directMaint[w.MonitorID] = true
}
}
resolved := make(map[int]bool)
e.mu.RLock()
for id, site := range e.liveState {
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
resolved[id] = true
}
}
e.mu.RUnlock()
e.maintCacheMu.Lock()
e.maintCache = resolved
e.maintCacheMu.Unlock()
}
+53
View File
@@ -0,0 +1,53 @@
package monitor
import (
"context"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// Maintenance cache resolves parent relationships correctly.
func TestIsInMaintenance_UsesCache(t *testing.T) {
ms := newMockStore()
ms.maintenance[10] = true // direct maintenance on group
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child := models.Site{
SiteConfig: models.SiteConfig{ID: 20, Name: "child", Type: "http", ParentID: 10},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child)
e.refreshMaintenanceCache(context.Background())
if !e.isInMaintenance(10) {
t.Error("group should be in maintenance (direct)")
}
if !e.isInMaintenance(20) {
t.Error("child should be in maintenance (parent)")
}
if e.isInMaintenance(99) {
t.Error("unknown monitor should not be in maintenance")
}
}
// Global maintenance (monitor_id=0) applies to all monitors.
func TestIsInMaintenance_GlobalMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[0] = true
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
if !e.isInMaintenance(1) {
t.Error("all monitors should be in maintenance during global window")
}
}
+24 -795
View File
@@ -4,14 +4,12 @@ import (
"context"
"crypto/tls"
"fmt"
"math/rand/v2"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
)
@@ -24,23 +22,16 @@ const (
maintPruneInterval = 15 * time.Minute
defaultMaintRetention = 7 * 24 * time.Hour
dbWriteBuffer = 4096
alertSendTimeout = 30 * time.Second
dbPruneInterval = 10 * time.Minute
)
type AlertHealth struct {
LastSendAt time.Time
LastSendOK bool
LastError string
SendCount int
FailCount int
}
type Engine struct {
mu sync.RWMutex
liveState map[int]models.Site
logMu sync.RWMutex
logStore []string
logStore []models.LogEntry
activeMu sync.RWMutex
isActive bool
@@ -73,6 +64,7 @@ type Engine struct {
dbWrites chan dbWrite
writerWG sync.WaitGroup
checkerWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
stopOnce sync.Once
}
@@ -99,6 +91,7 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
allowPrivateTargets: allowPrivateTargets,
maintRetention: defaultMaintRetention,
dbWrites: make(chan dbWrite, dbWriteBuffer),
ctx: context.Background(),
db: s,
strictClient: &http.Client{
Transport: &http.Transport{
@@ -152,11 +145,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 +161,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
@@ -207,6 +202,8 @@ func (e *Engine) dbWriter(ctx context.Context) {
}
// drainWrites flushes everything still buffered, best-effort, at shutdown.
// Uses context.Background because the engine ctx is already cancelled when
// this runs — writes still need to reach the DB.
func (e *Engine) drainWrites() {
for {
select {
@@ -245,169 +242,33 @@ func (e *Engine) Stop() {
})
}
func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
func (e *Engine) InitLogs(ctx context.Context) {
entries, err := e.db.LoadLogs(ctx, 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
// "last sent" / health state on startup instead of resetting every channel to "never".
func (e *Engine) InitAlertHealth() {
records, err := e.db.LoadAlertHealth(context.Background())
if err != nil {
return
}
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
for id, r := range records {
e.alertHealth[id] = AlertHealth{
LastSendAt: r.LastSendAt,
LastSendOK: r.LastSendOK,
LastError: r.LastError,
SendCount: r.SendCount,
FailCount: r.FailCount,
}
}
}
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
}
func (e *Engine) SetActive(active bool) {
e.activeMu.Lock()
defer e.activeMu.Unlock()
if e.isActive != active {
e.isActive = active
status := "RESUMED (Active)"
if !active {
status = "PAUSED (Passive)"
}
e.AddLog(fmt.Sprintf("Engine %s", status))
}
}
func (e *Engine) IsActive() bool {
e.activeMu.RLock()
defer e.activeMu.RUnlock()
return e.isActive
}
func (e *Engine) GetAllSites() []models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
sites := make([]models.Site, 0, len(e.liveState))
for _, s := range e.liveState {
sites = append(sites, s)
}
return sites
}
func (e *Engine) GetLiveState() map[int]models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
cp := make(map[int]models.Site, len(e.liveState))
for k, v := range e.liveState {
cp[k] = v
}
return cp
}
func (e *Engine) RecordHeartbeat(token string) bool {
if !e.IsActive() {
return false
}
e.mu.RLock()
targetID, ok := e.tokenIndex[token]
e.mu.RUnlock()
if !ok {
return false
}
var (
prevStatus models.Status
name string
alertID int
downSince time.Time
)
_, exists := e.applyState(targetID, func(s *models.Site) {
prevStatus = s.Status
name = s.Name
alertID = s.AlertID
downSince = s.StatusChangedAt // captured before mutation = when it went down
s.LastCheck = time.Now()
s.Status = models.StatusUp
s.FailureCount = 0
s.Latency = 0
s.LastError = ""
s.LastSuccessAt = time.Now()
if prevStatus != models.StatusUp {
s.StatusChangedAt = time.Now()
}
})
if !exists {
return false
}
switch prevStatus {
case models.StatusPending:
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
case models.StatusLate:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
case models.StatusStale:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
case models.StatusDown:
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
}
e.recordCheck(targetID, 0, true)
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
}
return true
}
func (e *Engine) addToTokenIndex(site models.Site) {
if site.Type == "push" && site.Token != "" {
e.tokenIndex[site.Token] = site.ID
}
}
func (e *Engine) removeFromTokenIndex(id int) {
for token, sid := range e.tokenIndex {
if sid == id {
delete(e.tokenIndex, token)
return
}
}
}
func (e *Engine) Start(ctx context.Context) {
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
// trace the cross-method call, and cancelling the parent reaps this child
// regardless, so the leak it warns about can't occur.
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
e.ctx = ctx
e.writerWG.Add(1)
go e.dbWriter(ctx)
@@ -422,8 +283,6 @@ func (e *Engine) Start(ctx context.Context) {
default:
}
e.refreshMaintenanceCache(ctx)
configs, err := e.db.GetSites(ctx)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
@@ -466,6 +325,10 @@ func (e *Engine) Start(ctx context.Context) {
}
}
// Refresh after sites load so the cache covers newly added sites.
// On first iteration liveState was empty before the loop above.
e.refreshMaintenanceCache(ctx)
e.mu.RLock()
var vanished []int
for id := range e.liveState {
@@ -493,637 +356,3 @@ func (e *Engine) Start(ctx context.Context) {
e.maintenancePruner(ctx)
}()
}
func (e *Engine) maintenancePruner(ctx context.Context) {
ticker := time.NewTicker(maintPruneInterval)
defer ticker.Stop()
e.pruneMaintenanceWindows(ctx)
for {
select {
case <-ticker.C:
e.pruneMaintenanceWindows(ctx)
case <-ctx.Done():
return
}
}
}
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
if err != nil {
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
return
}
if pruned > 0 {
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
}
}
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
e.mu.Lock()
if existing, ok := e.liveState[cfg.ID]; ok {
e.removeFromTokenIndex(cfg.ID)
existing.SiteConfig = cfg
e.liveState[cfg.ID] = existing
e.addToTokenIndex(existing)
}
e.mu.Unlock()
e.signalRecheck(cfg.ID)
}
func (e *Engine) getRecheckChan(id int) chan struct{} {
e.recheckMu.Lock()
defer e.recheckMu.Unlock()
ch, ok := e.recheck[id]
if !ok {
ch = make(chan struct{}, 1)
e.recheck[id] = ch
}
return ch
}
func (e *Engine) signalRecheck(id int) {
ch := e.getRecheckChan(id)
select {
case ch <- struct{}{}:
default:
}
}
func (e *Engine) RemoveSite(id int) {
e.mu.Lock()
e.removeFromTokenIndex(id)
delete(e.liveState, id)
e.mu.Unlock()
e.removeHistory(id)
e.probeResultsMu.Lock()
delete(e.probeResults, id)
e.probeResultsMu.Unlock()
e.recheckMu.Lock()
delete(e.recheck, id)
e.recheckMu.Unlock()
}
func (e *Engine) ToggleSitePause(id int) bool {
var (
paused bool
name string
)
_, ok := e.applyState(id, func(s *models.Site) {
s.Paused = !s.Paused
paused = s.Paused
name = s.Name
})
if !ok {
return false
}
if paused {
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
}
return paused
}
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
recheckCh := e.getRecheckChan(id)
// Stagger initial check to avoid thundering herd on startup
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(stagger):
case <-ctx.Done():
return
}
e.checkByID(ctx, id)
for {
select {
case <-ctx.Done():
return
default:
}
if !e.IsActive() {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists {
return
}
if site.Paused {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
interval := site.Interval
if interval < minCheckInterval {
interval = minCheckInterval
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done():
return
case <-recheckCh:
}
e.checkByID(ctx, id)
}
}
// applyState atomically reads, mutates, and writes back the live entry for id.
// The mutator runs under the engine write lock and receives a pointer to the
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
// never clobbered by a stale snapshot. The mutator must only touch runtime /
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
// are owned by UpdateSiteConfig and must not be written here. Returns the
// post-mutation copy and whether the site still exists.
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
e.mu.Lock()
defer e.mu.Unlock()
cur, ok := e.liveState[id]
if !ok {
return models.Site{}, false
}
mutate(&cur)
e.liveState[id] = cur
return cur, true
}
func (e *Engine) checkByID(ctx context.Context, id int) {
if !e.IsActive() {
return
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists || site.Paused {
return
}
switch site.Type {
case "push":
e.checkPush(ctx, site)
case "group":
e.checkGroup(ctx, site)
default:
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
updatedSite.Latency = time.Duration(result.LatencyNs)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
}
}
func (e *Engine) checkPush(_ context.Context, site models.Site) {
if site.Status == models.StatusPending {
return
}
interval := time.Duration(site.Interval) * time.Second
grace := interval / 2
if grace < minPushGrace {
grace = minPushGrace
}
overdue := site.LastCheck.Add(interval)
staleMark := overdue.Add(grace / 2)
graceEnd := overdue.Add(grace)
now := time.Now()
if now.After(graceEnd) {
if site.Status != models.StatusDown {
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
}
} else if now.After(staleMark) {
if site.Status != models.StatusStale {
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
}
} else if now.After(overdue) {
if site.Status != models.StatusLate {
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
}
}
}
// handleStatusChange folds a check result into the live state. snap is the
// stale snapshot the check ran against; the actual mutation is applied onto the
// CURRENT live entry via applyState, so a concurrent pause / config edit /
// heartbeat is never reverted by this write. Logs and alerts are emitted after
// the lock is released, off the critical section.
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
if !e.IsActive() {
return
}
inMaint := e.isInMaintenance(snap.ID)
status := models.Status(rawStatus)
var (
prev, next models.Status
name, typ string
alertID int
failCount, maxRetries int
confirmedDown bool
failedCheck bool
downSince time.Time
sslWarnFire bool
sslDays int
skipped bool
changed bool
)
_, exists := e.applyState(snap.ID, func(s *models.Site) {
// A non-UP result computed from a stale snapshot must not override a
// heartbeat (or newer check) that landed while we were evaluating.
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
skipped = true
return
}
prev = s.Status
name = s.Name
typ = s.Type
alertID = s.AlertID
maxRetries = s.MaxRetries
downSince = s.StatusChangedAt
// Fresh check results (measured by the run against snap).
s.StatusCode = code
s.Latency = snap.Latency
s.LastCheck = snap.LastCheck
s.HasSSL = snap.HasSSL
s.CertExpiry = snap.CertExpiry
s.LastError = errorReason
if status == models.StatusUp {
s.LastSuccessAt = time.Now()
s.LastError = ""
}
// Status + failure-count transition, based on the CURRENT live status.
if status == models.StatusUp {
s.FailureCount = 0
s.Status = models.StatusUp
} else {
if s.FailureCount <= s.MaxRetries {
s.FailureCount++
}
if s.FailureCount > s.MaxRetries {
if s.Status != status {
confirmedDown = true
}
s.Status = status
s.FailureCount = s.MaxRetries + 1
} else {
failedCheck = true
}
}
failCount = s.FailureCount
if s.Status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
s.StatusChangedAt = time.Now()
}
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
if typ == "http" && s.CheckSSL && s.HasSSL {
days := int(time.Until(s.CertExpiry).Hours() / 24)
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
sslWarnFire = true
sslDays = days
s.SentSSLWarning = true
} else if days > s.ExpiryThreshold {
s.SentSSLWarning = false
}
}
next = s.Status
changed = next != prev
})
if !exists || skipped {
return
}
e.recordCheck(snap.ID, latency, status == models.StatusUp)
if confirmedDown {
if errorReason != "" {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
}
} else if failedCheck {
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
}
if changed && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
}
if sslWarnFire {
if !inMaint {
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
} else {
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
}
}
if prev == models.StatusUp && next == models.StatusLate {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
}
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
} else {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
if errorReason != "" {
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
}
if typ == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
}
e.triggerAlert(alertID, "🚨 ALERT", msg)
}
}
if prev.IsBroken() && next == models.StatusUp {
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
if !inMaint {
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
}
}
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
}
}
func (e *Engine) triggerAlert(alertID int, title, message string) {
if alertID <= 0 {
return
}
cfg, err := e.db.GetAlert(context.Background(), alertID)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return
}
provider := alert.GetProvider(cfg)
if provider != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := provider.Send(ctx, title, message); err != nil {
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
e.recordAlertResult(alertID, false, err.Error())
} else {
e.recordAlertResult(alertID, true, "")
}
}()
}
}
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
h := e.alertHealth[alertID]
h.LastSendAt = time.Now()
h.LastSendOK = ok
h.SendCount++
if ok {
h.LastError = ""
} else {
h.LastError = errMsg
h.FailCount++
}
e.alertHealth[alertID] = h
// Persist so health survives restarts; DB IO off the alert path.
e.enqueueWrite(writeAlertHealth{rec: models.AlertHealthRecord{
AlertID: alertID,
LastSendAt: h.LastSendAt,
LastSendOK: h.LastSendOK,
LastError: h.LastError,
SendCount: h.SendCount,
FailCount: h.FailCount,
}})
}
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
e.alertHealthMu.RLock()
defer e.alertHealthMu.RUnlock()
return e.alertHealth[alertID]
}
func (e *Engine) TestAlert(alertID int) error {
cfg, err := e.db.GetAlert(context.Background(), alertID)
if err != nil {
return fmt.Errorf("failed to load alert: %w", err)
}
provider := alert.GetProvider(cfg)
if provider == nil {
return fmt.Errorf("no provider for type %q", cfg.Type)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
if err != nil {
e.recordAlertResult(alertID, false, err.Error())
return err
}
e.recordAlertResult(alertID, true, "")
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
return nil
}
func (e *Engine) isInMaintenance(monitorID int) bool {
e.maintCacheMu.RLock()
defer e.maintCacheMu.RUnlock()
return e.maintCache[monitorID]
}
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
if err != nil {
return
}
directMaint := make(map[int]bool)
var globalMaint bool
for _, w := range windows {
if w.MonitorID == 0 {
globalMaint = true
} else {
directMaint[w.MonitorID] = true
}
}
resolved := make(map[int]bool)
e.mu.RLock()
for id, site := range e.liveState {
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
resolved[id] = true
}
}
e.mu.RUnlock()
e.maintCacheMu.Lock()
e.maintCache = resolved
e.maintCacheMu.Unlock()
}
func (e *Engine) GetDisplayStatus(site models.Site) string {
if site.Paused {
return "PAUSED"
}
if e.isInMaintenance(site.ID) {
return "MAINT"
}
return string(site.Status)
}
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
e.mu.RLock()
status := models.StatusUp
hasChildren := false
for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
}
hasChildren = true
if child.Paused || e.isInMaintenance(child.ID) {
continue
}
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
status = models.StatusDown
} else if child.Status == models.StatusStale && status != models.StatusDown {
status = models.StatusStale
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
status = models.StatusLate
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
status = models.StatusPending
}
}
e.mu.RUnlock()
if !hasChildren {
status = models.StatusPending
}
e.applyState(site.ID, func(s *models.Site) {
s.Status = status
})
e.recordCheck(site.ID, 0, !status.IsBroken())
}
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
}
// SetAggStrategy must be called before Start: the field is read by the probe
// aggregation path without synchronization.
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
e.aggStrategy = strategy
}
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
e.mu.RLock()
site, exists := e.liveState[siteID]
e.mu.RUnlock()
if !exists {
return
}
staleAfter := time.Duration(site.Interval) * time.Second * 3
if staleAfter < time.Minute {
staleAfter = time.Minute
}
now := time.Now()
e.probeResultsMu.Lock()
if e.probeResults[siteID] == nil {
e.probeResults[siteID] = make(map[string]NodeResult)
}
e.probeResults[siteID][nodeID] = NodeResult{
NodeID: nodeID,
IsUp: isUp,
LatencyNs: latencyNs,
CheckedAt: now,
ErrorReason: errorReason,
}
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
for id, r := range e.probeResults[siteID] {
if now.Sub(r.CheckedAt) > staleAfter {
delete(e.probeResults[siteID], id)
continue
}
results = append(results, r)
}
e.probeResultsMu.Unlock()
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
probeStatus := models.StatusUp
if !aggUp {
probeStatus = models.StatusDown
}
updatedSite := site
updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
}
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
src := e.probeResults[siteID]
cp := make(map[string]NodeResult, len(src))
for k, v := range src {
cp[k] = v
}
return cp
}
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(context.Background(), siteID, limit)
if err != nil {
return nil
}
return changes
}
func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange {
changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since)
if err != nil {
return nil
}
return changes
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"time"
)
const dialTimeout = 10 * time.Second
var privateRanges []*net.IPNet
func init() {
@@ -60,7 +62,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
dialer := &net.Dialer{Timeout: dialTimeout}
for _, ip := range ips {
target := net.JoinHostPort(ip.IP.String(), port)
conn, err := dialer.DialContext(ctx, network, target)
+158
View File
@@ -0,0 +1,158 @@
package monitor
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (e *Engine) SetActive(active bool) {
e.activeMu.Lock()
defer e.activeMu.Unlock()
if e.isActive != active {
e.isActive = active
status := "RESUMED (Active)"
if !active {
status = "PAUSED (Passive)"
}
e.AddLog(fmt.Sprintf("Engine %s", status))
}
}
func (e *Engine) IsActive() bool {
e.activeMu.RLock()
defer e.activeMu.RUnlock()
return e.isActive
}
func (e *Engine) GetAllSites() []models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
sites := make([]models.Site, 0, len(e.liveState))
for _, s := range e.liveState {
sites = append(sites, s)
}
return sites
}
func (e *Engine) GetLiveState() map[int]models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
cp := make(map[int]models.Site, len(e.liveState))
for k, v := range e.liveState {
cp[k] = v
}
return cp
}
func (e *Engine) addToTokenIndex(site models.Site) {
if site.Type == "push" && site.Token != "" {
e.tokenIndex[site.Token] = site.ID
}
}
func (e *Engine) removeFromTokenIndex(id int) {
for token, sid := range e.tokenIndex {
if sid == id {
delete(e.tokenIndex, token)
return
}
}
}
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
e.mu.Lock()
if existing, ok := e.liveState[cfg.ID]; ok {
e.removeFromTokenIndex(cfg.ID)
existing.SiteConfig = cfg
e.liveState[cfg.ID] = existing
e.addToTokenIndex(existing)
}
e.mu.Unlock()
e.signalRecheck(cfg.ID)
}
func (e *Engine) RemoveSite(id int) {
e.mu.Lock()
e.removeFromTokenIndex(id)
delete(e.liveState, id)
e.mu.Unlock()
e.removeHistory(id)
e.probeResultsMu.Lock()
delete(e.probeResults, id)
e.probeResultsMu.Unlock()
e.recheckMu.Lock()
delete(e.recheck, id)
e.recheckMu.Unlock()
}
func (e *Engine) ToggleSitePause(id int) bool {
var (
paused bool
name string
)
_, ok := e.applyState(id, func(s *models.Site) {
s.Paused = !s.Paused
paused = s.Paused
name = s.Name
})
if !ok {
return false
}
if paused {
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
}
return paused
}
// applyState atomically reads, mutates, and writes back the live entry for id.
// The mutator runs under the engine write lock and receives a pointer to the
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
// never clobbered by a stale snapshot. The mutator must only touch runtime /
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
// are owned by UpdateSiteConfig and must not be written here. Returns the
// post-mutation copy and whether the site still exists.
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
e.mu.Lock()
defer e.mu.Unlock()
cur, ok := e.liveState[id]
if !ok {
return models.Site{}, false
}
mutate(&cur)
e.liveState[id] = cur
return cur, true
}
func (e *Engine) GetDisplayStatus(site models.Site) string {
if site.Paused {
return "PAUSED"
}
if e.isInMaintenance(site.ID) {
return "MAINT"
}
return string(site.Status)
}
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
if err != nil {
return nil
}
return changes
}
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
if err != nil {
return nil
}
return changes
}
+133
View File
@@ -0,0 +1,133 @@
package monitor
import (
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com"},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond},
}
injectSite(e, site)
updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60}
e.UpdateSiteConfig(updated)
s, _ := getSite(e, 1)
if s.URL != "http://new.com" {
t.Errorf("expected URL updated, got %s", s.URL)
}
if s.Status != "DOWN" {
t.Errorf("expected Status preserved, got %s", s.Status)
}
if s.FailureCount != 3 {
t.Errorf("expected FailureCount preserved, got %d", s.FailureCount)
}
if s.Latency != 100*time.Millisecond {
t.Errorf("expected Latency preserved, got %v", s.Latency)
}
}
func TestRemoveSite_CleansUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.recordCheck(1, 5*time.Millisecond, true)
e.RemoveSite(1)
if _, ok := getSite(e, 1); ok {
t.Error("expected site removed from liveState")
}
if e.RecordHeartbeat("tok1") {
t.Error("expected token removed from index")
}
if _, ok := e.GetHistory(1); ok {
t.Error("expected history removed")
}
}
func TestToggleSitePause(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
paused := e.ToggleSitePause(1)
if !paused {
t.Error("expected paused=true after first toggle")
}
s, _ := getSite(e, 1)
if !s.Paused {
t.Error("expected Paused=true in state")
}
paused = e.ToggleSitePause(1)
if paused {
t.Error("expected paused=false after second toggle")
}
}
func TestToggleSitePause_NonexistentSite(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
if e.ToggleSitePause(999) {
t.Error("expected false for nonexistent site")
}
}
func TestGetAllSites_ReturnsCopy(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
SiteState: models.SiteState{Status: "UP"},
})
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "s2"},
SiteState: models.SiteState{Status: "DOWN"},
})
sites := e.GetAllSites()
if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites))
}
sites[0].Name = "mutated"
fresh := e.GetAllSites()
for _, s := range fresh {
if s.Name == "mutated" {
t.Error("GetAllSites returned reference, not copy")
}
}
}
func TestGetLiveState_ReturnsCopy(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
SiteState: models.SiteState{Status: "UP"},
})
state := e.GetLiveState()
state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}}
fresh := e.GetLiveState()
if fresh[1].Name == "mutated" {
t.Error("GetLiveState returned reference, not copy")
}
}
+7 -2
View File
@@ -14,6 +14,11 @@ import (
// guard.
const maxVisitors = 10000
const (
visitorCleanupInterval = 5 * time.Minute
visitorIdleCutoff = 10 * time.Minute
)
type visitor struct {
tokens float64
lastSeen time.Time
@@ -90,13 +95,13 @@ func (rl *RateLimiter) evictOldest() {
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
ticker := time.NewTicker(visitorCleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
rl.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute)
cutoff := time.Now().Add(-visitorIdleCutoff)
for ip, v := range rl.visitors {
if v.lastSeen.Before(cutoff) {
delete(rl.visitors, ip)
+20 -12
View File
@@ -45,15 +45,27 @@ type Server struct {
statusRL *RateLimiter
}
const (
pushRateLimit = 60
probeRateLimit = 30
backupRateLimit = 10
statusRateLimit = 120
httpReadHeaderTimeout = 10 * time.Second
httpReadTimeout = 30 * time.Second
httpWriteTimeout = 60 * time.Second
httpIdleTimeout = 120 * time.Second
)
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
return &Server{
cfg: cfg,
store: s,
eng: eng,
pushRL: NewRateLimiter(60, cfg.TrustedProxies),
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
statusRL: NewRateLimiter(120, cfg.TrustedProxies),
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
}
}
@@ -77,10 +89,10 @@ func (s *Server) Start() *http.Server {
httpSrv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
ReadHeaderTimeout: httpReadHeaderTimeout,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
}
go func() {
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
@@ -159,10 +171,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
+45 -14
View File
@@ -77,8 +77,12 @@ func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.Mai
// --- Helpers ---
func freePort() int {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port
ln.Close()
return port
@@ -95,7 +99,7 @@ func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServ
t.Helper()
ms := newMockStore()
eng := monitor.NewEngine(ms)
port := freePort()
port := freePort(t)
srv := Start(ServerConfig{
Port: port,
@@ -219,8 +223,8 @@ func TestHealth_WrongSecret(t *testing.T) {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
if resp.StatusCode != 200 {
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
}
}
@@ -285,7 +289,10 @@ func TestImport_MethodNotAllowed(t *testing.T) {
func TestImport_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(models.Backup{})
body, err := json.Marshal(models.Backup{})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
if err != nil {
t.Fatal(err)
@@ -301,7 +308,10 @@ func TestImport_Success(t *testing.T) {
backup := models.Backup{
Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}},
}
body, _ := json.Marshal(backup)
body, err := json.Marshal(backup)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
if err != nil {
t.Fatal(err)
@@ -333,9 +343,12 @@ func TestImport_InvalidJSON(t *testing.T) {
func TestProbeRegister_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{
body, err := json.Marshal(map[string]string{
"id": "node-1", "name": "US East", "region": "us-east",
})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil {
t.Fatal(err)
@@ -356,7 +369,10 @@ func TestProbeRegister_Success(t *testing.T) {
func TestProbeRegister_MissingID(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{"name": "test"})
body, err := json.Marshal(map[string]string{"name": "test"})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil {
t.Fatal(err)
@@ -369,7 +385,10 @@ func TestProbeRegister_MissingID(t *testing.T) {
func TestProbeRegister_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{"id": "node-1"})
body, err := json.Marshal(map[string]string{"id": "node-1"})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
if err != nil {
t.Fatal(err)
@@ -384,12 +403,15 @@ func TestProbeRegister_Unauthorized(t *testing.T) {
func TestProbeResults_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]any{
body, err := json.Marshal(map[string]any{
"node_id": "node-1",
"results": []map[string]any{
{"site_id": 1, "latency_ns": 5000000, "is_up": true},
},
})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil {
t.Fatal(err)
@@ -402,9 +424,12 @@ func TestProbeResults_Success(t *testing.T) {
func TestProbeResults_MissingNodeID(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]any{
body, err := json.Marshal(map[string]any{
"results": []map[string]any{},
})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil {
t.Fatal(err)
@@ -573,7 +598,10 @@ func TestClientIP_TrustedProxyHandling(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
r.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
@@ -591,7 +619,10 @@ func TestRateLimit_SpoofedXFFCannotBypass(t *testing.T) {
rl := NewRateLimiter(60, nil) // no trusted proxies
allowed := 0
for i := 0; i < 200; i++ {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
r.RemoteAddr = "203.0.113.9:5000"
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i%256))
if rl.Allow(clientIP(r, rl.trusted)) {
+8 -2
View File
@@ -75,8 +75,14 @@ func TestEncryptorUniqueCiphertexts(t *testing.T) {
t.Fatal(err)
}
a, _ := enc.Encrypt("same")
b, _ := enc.Encrypt("same")
a, err := enc.Encrypt("same")
if err != nil {
t.Fatalf("first encrypt: %v", err)
}
b, err := enc.Encrypt("same")
if err != nil {
t.Fatalf("second encrypt: %v", err)
}
if a == b {
t.Error("two encryptions of same plaintext should produce different ciphertexts")
}
+22 -332
View File
@@ -5,8 +5,8 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -17,6 +17,10 @@ const (
maxLogRows = 200
maxStateChangesPerSite = 5000
maxMaintenanceExport = 1000
maxOpenConns = 25
maxIdleConns = 5
connMaxLifetime = 5 * time.Minute
tokenByteLen = 16
)
type SQLStore struct {
@@ -31,9 +35,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetMaxOpenConns(maxOpenConns)
db.SetMaxIdleConns(maxIdleConns)
db.SetConnMaxLifetime(connMaxLifetime)
_, isDollar := dialect.(*PostgresDialect)
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
}
@@ -61,7 +65,7 @@ func (s *SQLStore) q(query string) string {
}
func generateToken() (string, error) {
b := make([]byte, 16)
b := make([]byte, tokenByteLen)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("crypto/rand failed: %w", err)
}
@@ -213,40 +217,6 @@ func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteC
return st, err
}
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
}
return a, nil
}
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) {
token := ""
if site.Type == "push" {
@@ -273,89 +243,6 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
return int(id), err
}
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
stored, err := s.marshalSettings(settings)
if err != nil {
return 0, err
}
if s.dollar {
var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err
}
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
}
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
return alerts, rows.Err()
}
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
}
return a, nil
}
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err
}
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err
}
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil
}
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users")
if err != nil {
@@ -388,85 +275,6 @@ func (s *SQLStore) DeleteUser(ctx context.Context, id int) error {
return err
}
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
siteID, fromStatus, toStatus, errorReason)
return err
}
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
}
// SaveCheckFromNode inserts a single check row. Retention is handled out of
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
// path a plain INSERT.
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
return err
}
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
// site, across all sites, in one pass. Intended to run periodically.
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
FROM check_history
) ranked WHERE rn > %d
)`, maxCheckHistory)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
// otherwise unbounded growth of a flapping monitor's history.
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
FROM state_changes
) ranked WHERE rn > %d
)`, maxStateChangesPerSite)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error {
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
return err
@@ -554,145 +362,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)
}
return logs, rows.Err()
}
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
result := make(map[int][]models.CheckRecord)
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= ?`), limit)
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
return result, err
// 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:])
}
}
result[r.SiteID] = append(result[r.SiteID], r)
entries = append(entries, e)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result, rows.Err()
}
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
var mw models.MaintenanceWindow
var endTime sql.NullTime
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
return mw, err
}
if endTime.Valid {
mw.EndTime = endTime.Time
}
return mw, nil
}
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() {
mw.StartTime = time.Now()
}
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err
}
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
return nil
}
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
cutoff := time.Now().Add(-retention)
result, err := s.db.ExecContext(ctx,
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
cutoff,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
AND (monitor_id = 0 OR monitor_id = ?
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
monitorID, monitorID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
return entries, rows.Err()
}
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) {
+126
View File
@@ -0,0 +1,126 @@
package store
import (
"context"
"encoding/json"
"fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
}
return a, nil
}
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
stored, err := s.marshalSettings(settings)
if err != nil {
return 0, err
}
if s.dollar {
var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err
}
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
}
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
return alerts, rows.Err()
}
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
}
return a, nil
}
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err
}
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err
}
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package store
import (
"context"
"strings"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestAlertCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Type != "discord" {
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
}
if alerts[0].Settings["url"] != "https://example.com/hook" {
t.Errorf("settings url mismatch")
}
a, err := s.GetAlert(context.Background(), alerts[0].ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Name != "Discord" {
t.Errorf("expected name 'Discord', got '%s'", a.Name)
}
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err)
}
a, err = s.GetAlert(context.Background(), a.ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type)
}
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
alerts, err = s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
}
}
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
func TestImportData_EncryptsAlertSettings(t *testing.T) {
s := newTestStore(t)
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
s.SetEncryptor(enc)
backup := models.Backup{
Alerts: []models.AlertConfig{
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
},
}
if err := s.ImportData(context.Background(), backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
var raw string
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
t.Fatalf("query settings: %v", err)
}
if !strings.HasPrefix(raw, encryptedPrefix) {
t.Errorf("imported settings not encrypted: %q", raw)
}
if strings.Contains(raw, "SECRET") {
t.Errorf("plaintext secret found in stored column: %q", raw)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
t.Errorf("decrypt round-trip failed: %+v", alerts)
}
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
siteID, fromStatus, toStatus, errorReason)
return err
}
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
}
// SaveCheckFromNode inserts a single check row. Retention is handled out of
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
// path a plain INSERT.
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
return err
}
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
// site, across all sites, in one pass. Intended to run periodically.
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
FROM check_history
) ranked WHERE rn > %d
)`, maxCheckHistory)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
// otherwise unbounded growth of a flapping monitor's history.
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
FROM state_changes
) ranked WHERE rn > %d
)`, maxStateChangesPerSite)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
result := make(map[int][]models.CheckRecord)
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= ?`), limit)
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
return result, err
}
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result, rows.Err()
}
+134
View File
@@ -0,0 +1,134 @@
package store
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestCheckHistory(t *testing.T) {
s := newTestStore(t)
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 10)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != 2 {
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
}
if len(history[2]) != 1 {
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
}
upCount := 0
for _, r := range history[1] {
if r.IsUp {
upCount++
}
}
if upCount != 1 {
t.Errorf("expected 1 up record for site 1, got %d", upCount)
}
}
func TestPruneCheckHistory(t *testing.T) {
s := newTestStore(t)
for i := 0; i < maxCheckHistory+5; i++ {
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 1: %v", err)
}
}
for i := 0; i < 3; i++ {
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
}
if err := s.PruneCheckHistory(context.Background()); err != nil {
t.Fatalf("PruneCheckHistory: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != maxCheckHistory {
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
}
if len(history[2]) != 3 {
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
}
}
func TestDeleteSiteCascade(t *testing.T) {
s := newTestStore(t)
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
if err := s.AddSite(context.Background(), site); err != nil {
t.Fatalf("AddSite: %v", err)
}
sites, err := s.GetSites(context.Background())
if err != nil {
t.Fatalf("GetSites: %v", err)
}
siteID := sites[0].ID
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
t.Fatalf("SaveStateChange: %v", err)
}
mw := models.MaintenanceWindow{
MonitorID: siteID,
Title: "Test MW",
Type: "maintenance",
StartTime: time.Now(),
}
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
if err := s.DeleteSite(context.Background(), siteID); err != nil {
t.Fatalf("DeleteSite: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 100)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[siteID]) != 0 {
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
}
changes, err := s.GetStateChanges(context.Background(), siteID, 100)
if err != nil {
t.Fatalf("GetStateChanges: %v", err)
}
if len(changes) != 0 {
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
}
windows, err := s.GetActiveMaintenanceWindows(context.Background())
if err != nil {
t.Fatalf("GetActiveMaintenanceWindows: %v", err)
}
for _, w := range windows {
if w.MonitorID == siteID {
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
var mw models.MaintenanceWindow
var endTime sql.NullTime
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
return mw, err
}
if endTime.Valid {
mw.EndTime = endTime.Time
}
return mw, nil
}
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
var timeClause string
var args []interface{}
if endTime.IsZero() {
timeClause = "(end_time IS NULL OR end_time > ?)"
args = append(args, startTime)
} else {
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
args = append(args, startTime, endTime)
}
var scopeClause string
if monitorID == 0 {
scopeClause = "1=1"
} else {
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
args = append(args, monitorID, monitorID)
}
query := fmt.Sprintf(
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
timeClause, scopeClause,
)
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return nil, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() {
mw.StartTime = time.Now()
}
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err
}
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
return nil
}
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
cutoff := time.Now().Add(-retention)
result, err := s.db.ExecContext(ctx,
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
cutoff,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
AND (monitor_id = 0 OR monitor_id = ?
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
monitorID, monitorID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
+168
View File
@@ -0,0 +1,168 @@
package store
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
s := newTestStore(t)
now := time.Now()
// Expired 10 days ago — should be pruned with 7d retention.
old := models.MaintenanceWindow{
MonitorID: 0,
Title: "Old Window",
Type: "maintenance",
StartTime: now.Add(-11 * 24 * time.Hour),
EndTime: now.Add(-10 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
t.Fatalf("AddMaintenanceWindow (old): %v", err)
}
// Expired 1 day ago — within 7d retention, should survive.
recent := models.MaintenanceWindow{
MonitorID: 0,
Title: "Recent Window",
Type: "maintenance",
StartTime: now.Add(-2 * 24 * time.Hour),
EndTime: now.Add(-1 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
}
// Ongoing — no end time, should survive.
ongoing := models.MaintenanceWindow{
MonitorID: 0,
Title: "Ongoing Window",
Type: "maintenance",
StartTime: now.Add(-1 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
}
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
if err != nil {
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
}
if pruned != 1 {
t.Errorf("expected 1 pruned, got %d", pruned)
}
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
if err != nil {
t.Fatalf("GetAllMaintenanceWindows: %v", err)
}
if len(all) != 2 {
t.Fatalf("expected 2 remaining windows, got %d", len(all))
}
for _, w := range all {
if w.Title == "Old Window" {
t.Error("old window should have been pruned")
}
}
}
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
if err := s.AddSite(ctx, site); err != nil {
t.Fatalf("AddSite: %v", err)
}
now := time.Now()
active := models.MaintenanceWindow{
MonitorID: 1,
Title: "Deploy v2",
Type: "maintenance",
StartTime: now.Add(-30 * time.Minute),
EndTime: now.Add(30 * time.Minute),
}
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
ended := models.MaintenanceWindow{
MonitorID: 1,
Title: "Old deploy",
Type: "maintenance",
StartTime: now.Add(-3 * time.Hour),
EndTime: now.Add(-2 * time.Hour),
}
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
t.Run("same monitor overlaps", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 1 {
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
}
if overlaps[0].Title != "Deploy v2" {
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
}
})
t.Run("different monitor no overlap", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 0 {
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
}
})
t.Run("global window overlaps all", func(t *testing.T) {
global := models.MaintenanceWindow{
MonitorID: 0,
Title: "Global freeze",
Type: "maintenance",
StartTime: now.Add(-10 * time.Minute),
EndTime: now.Add(2 * time.Hour),
}
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 2 {
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
}
})
t.Run("indefinite window overlaps", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) < 1 {
t.Error("expected at least 1 overlap for indefinite window")
}
})
t.Run("ended window excluded", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 0 {
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
}
})
}
+1 -271
View File
@@ -3,9 +3,7 @@ package store
import (
"context"
"fmt"
"strings"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
@@ -74,60 +72,6 @@ func TestSiteCRUD(t *testing.T) {
}
}
func TestAlertCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Type != "discord" {
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
}
if alerts[0].Settings["url"] != "https://example.com/hook" {
t.Errorf("settings url mismatch")
}
a, err := s.GetAlert(context.Background(), alerts[0].ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Name != "Discord" {
t.Errorf("expected name 'Discord', got '%s'", a.Name)
}
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err)
}
a, err = s.GetAlert(context.Background(), a.ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type)
}
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
alerts, err = s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
}
}
func TestUserCRUD(t *testing.T) {
s := newTestStore(t)
@@ -301,89 +245,6 @@ func TestImportData_NilUsersPreservesExisting(t *testing.T) {
}
}
func TestCheckHistory(t *testing.T) {
s := newTestStore(t)
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 10)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != 2 {
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
}
if len(history[2]) != 1 {
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
}
upCount := 0
for _, r := range history[1] {
if r.IsUp {
upCount++
}
}
if upCount != 1 {
t.Errorf("expected 1 up record for site 1, got %d", upCount)
}
}
func TestDeleteSiteCascade(t *testing.T) {
s := newTestStore(t)
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
if err := s.AddSite(context.Background(), site); err != nil {
t.Fatalf("AddSite: %v", err)
}
sites, _ := s.GetSites(context.Background())
siteID := sites[0].ID
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
t.Fatalf("SaveStateChange: %v", err)
}
mw := models.MaintenanceWindow{
MonitorID: siteID,
Title: "Test MW",
Type: "maintenance",
StartTime: time.Now(),
}
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
if err := s.DeleteSite(context.Background(), siteID); err != nil {
t.Fatalf("DeleteSite: %v", err)
}
history, _ := s.LoadAllHistory(context.Background(), 100)
if len(history[siteID]) != 0 {
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
}
changes, _ := s.GetStateChanges(context.Background(), siteID, 100)
if len(changes) != 0 {
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
}
windows, _ := s.GetActiveMaintenanceWindows(context.Background())
for _, w := range windows {
if w.MonitorID == siteID {
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
}
}
}
func TestPruneLogs(t *testing.T) {
s := newTestStore(t)
@@ -407,7 +268,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")
@@ -416,134 +277,3 @@ func TestPruneLogs(t *testing.T) {
t.Error("oldest log survived prune")
}
}
func TestPruneCheckHistory(t *testing.T) {
s := newTestStore(t)
for i := 0; i < maxCheckHistory+5; i++ {
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 1: %v", err)
}
}
for i := 0; i < 3; i++ {
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
}
if err := s.PruneCheckHistory(context.Background()); err != nil {
t.Fatalf("PruneCheckHistory: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != maxCheckHistory {
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
}
if len(history[2]) != 3 {
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
}
}
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
s := newTestStore(t)
now := time.Now()
// Expired 10 days ago — should be pruned with 7d retention.
old := models.MaintenanceWindow{
MonitorID: 0,
Title: "Old Window",
Type: "maintenance",
StartTime: now.Add(-11 * 24 * time.Hour),
EndTime: now.Add(-10 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
t.Fatalf("AddMaintenanceWindow (old): %v", err)
}
// Expired 1 day ago — within 7d retention, should survive.
recent := models.MaintenanceWindow{
MonitorID: 0,
Title: "Recent Window",
Type: "maintenance",
StartTime: now.Add(-2 * 24 * time.Hour),
EndTime: now.Add(-1 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
}
// Ongoing — no end time, should survive.
ongoing := models.MaintenanceWindow{
MonitorID: 0,
Title: "Ongoing Window",
Type: "maintenance",
StartTime: now.Add(-1 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
}
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
if err != nil {
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
}
if pruned != 1 {
t.Errorf("expected 1 pruned, got %d", pruned)
}
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
if err != nil {
t.Fatalf("GetAllMaintenanceWindows: %v", err)
}
if len(all) != 2 {
t.Fatalf("expected 2 remaining windows, got %d", len(all))
}
for _, w := range all {
if w.Title == "Old Window" {
t.Error("old window should have been pruned")
}
}
}
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
func TestImportData_EncryptsAlertSettings(t *testing.T) {
s := newTestStore(t)
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
s.SetEncryptor(enc)
backup := models.Backup{
Alerts: []models.AlertConfig{
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
},
}
if err := s.ImportData(context.Background(), backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
var raw string
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
t.Fatalf("query settings: %v", err)
}
if !strings.HasPrefix(raw, encryptedPrefix) {
t.Errorf("imported settings not encrypted: %q", raw)
}
if strings.Contains(raw, "SECRET") {
t.Errorf("plaintext secret found in stored column: %q", raw)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
t.Errorf("decrypt round-trip failed: %+v", alerts)
}
}
+2 -1
View File
@@ -61,12 +61,13 @@ 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
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error)
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error
EndMaintenanceWindow(ctx context.Context, id int) error
DeleteMaintenanceWindow(ctx context.Context, id int) error
+6 -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 {
@@ -229,6 +229,10 @@ func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m
return nil, nil
}
func (m *BaseMock) GetOverlappingMaintenanceWindows(_ context.Context, _ int, _, _ time.Time) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package tui
import (
"fmt"
"strings"
"time"
)
func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string {
if len(latencies) == 0 || width < 20 || height < 1 {
return ""
}
var minMs, maxMs, sumMs int64
minMs = latencies[0].Milliseconds()
maxMs = minMs
upCount := 0
for i, l := range latencies {
ms := l.Milliseconds()
if i < len(statuses) && !statuses[i] {
continue
}
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
upCount++
}
var avgMs int64
if upCount > 0 {
avgMs = sumMs / int64(upCount)
}
maxLabel := fmt.Sprintf("%dms", maxMs)
minLabel := fmt.Sprintf("%dms", minMs)
labelW := len(maxLabel)
if len(minLabel) > labelW {
labelW = len(minLabel)
}
labelW += 1
chartW := width - labelW
if chartW > len(latencies) {
chartW = len(latencies)
}
if chartW < 10 {
chartW = 10
}
samples := latencies
sampledStatuses := statuses
if len(samples) > chartW {
samples = samples[len(samples)-chartW:]
if len(sampledStatuses) > chartW {
sampledStatuses = sampledStatuses[len(sampledStatuses)-chartW:]
}
}
spread := maxMs - minMs
if spread == 0 {
spread = 1
}
totalLevels := height * 8
levels := make([]int, len(samples))
for i, l := range samples {
ms := l.Milliseconds()
if i < len(sampledStatuses) && !sampledStatuses[i] {
levels[i] = 0
continue
}
lvl := int(float64(ms-minMs) / float64(spread) * float64(totalLevels-1))
if lvl < 1 && ms > 0 {
lvl = 1
}
if lvl >= totalLevels {
lvl = totalLevels - 1
}
levels[i] = lvl
}
labelStyle := m.st.subtleStyle
var rows []string
for row := height - 1; row >= 0; row-- {
var label string
switch row {
case height - 1:
label = fmt.Sprintf("%*s", labelW, maxLabel)
case 0:
label = fmt.Sprintf("%*s", labelW, minLabel)
default:
label = strings.Repeat(" ", labelW)
}
var sb strings.Builder
sb.WriteString(labelStyle.Render(label))
rowBase := row * 8
for i := range samples {
fill := levels[i] - rowBase
if fill <= 0 {
sb.WriteString(" ")
continue
}
if fill > 8 {
fill = 8
}
ch := string(sparkChars[fill-1])
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
if isDown {
sb.WriteString(m.st.dangerStyle.Render(ch))
} else {
sb.WriteString(m.latencyStyle(samples[i].Milliseconds(), nil).Render(ch))
}
}
rows = append(rows, sb.String())
}
stats := fmt.Sprintf("%*s Min %s Avg %s Max %s",
labelW, "",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
rows = append(rows, stats)
return strings.Join(rows, "\n")
}
+20
View File
@@ -0,0 +1,20 @@
package tui
import "time"
const (
nodeOnlineThreshold = 60 * time.Second
nodeStaleThreshold = 5 * time.Minute
uptimeGoodPct = 99.0
uptimeExcellentPct = 99.9
uptimeWarnPct = 95.0
uptimePrecisionPct = 99.99
stateHistoryLookback = 30 * 24 * time.Hour
stateHistoryDays = 30
stateHistoryLimit = 100
httpErrorThreshold = 400
errorDetailMaxLen = 30
)
+47 -14
View File
@@ -8,13 +8,14 @@ import (
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
tea "github.com/charmbracelet/bubbletea"
)
func loadCollapsed(s store.Store) map[int]bool {
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
m := make(map[int]bool)
raw, err := s.GetPreference(context.Background(), "collapsed_groups")
raw, err := s.GetPreference(ctx, "collapsed_groups")
if err != nil || raw == "" {
return m
}
@@ -49,7 +50,7 @@ func writeCmd(op string, fn func() error) tea.Cmd {
}
}
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site {
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
var groups, ungrouped []models.Site
children := make(map[int][]models.Site)
for _, s := range allSites {
@@ -62,14 +63,32 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []model
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
sortSlice := func(s []models.Site) {
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
sort.SliceStable(s, func(i, j int) bool {
var less bool
switch sortCol {
case sortName:
less = strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
case sortLatency:
less = s[i].Latency < s[j].Latency
default:
less = siteOrder(s[i]) < siteOrder(s[j])
}
if !sortAsc {
return less
}
return !less
})
}
for pid := range children {
c := children[pid]
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
sortSlice(c)
children[pid] = c
}
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
sortSlice(ungrouped)
var ordered []models.Site
for _, g := range groups {
@@ -99,14 +118,14 @@ func filterSites(sites []models.Site, needle string) []models.Site {
// separately via loadTabDataCmd.
func (m *Model) refreshLive() {
allSites := m.engine.GetAllSites()
ordered := sortSitesForDisplay(allSites, m.collapsed)
ordered := sortSitesForDisplay(allSites, m.collapsed, m.sortColumn, m.sortAsc)
if m.filterText != "" {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 {
if m.selectedID != 0 {
for i, s := range m.sites {
if s.ID == m.selectedID {
m.cursor = i
@@ -118,7 +137,7 @@ func (m *Model) refreshLive() {
}
func (m *Model) syncSelectedID() {
if m.currentTab == 0 && m.cursor < len(m.sites) {
if m.cursor < len(m.sites) {
m.selectedID = m.sites[m.cursor].ID
}
}
@@ -144,9 +163,9 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
m.tabSeq++
seq := m.tabSeq
st := m.store
ctx := m.ctx
isAdmin := m.isAdmin
return func() tea.Msg {
ctx := context.Background()
alerts, err := st.GetAllAlerts(ctx)
if err != nil {
return tabDataMsg{seq: seq, err: err}
@@ -173,8 +192,20 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
// goroutine. View renders the cached result rather than querying the DB.
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
var currentStatus models.Status
for _, s := range m.sites {
if s.ID == siteID {
currentStatus = s.Status
break
}
}
return func() tea.Msg {
return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)}
changes := eng.GetStateChanges(ctx, siteID, 5)
now := time.Now()
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
}
}
@@ -182,8 +213,9 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
// the UI goroutine.
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
return func() tea.Msg {
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 100)}
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
}
}
@@ -192,12 +224,13 @@ func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
// can be recognized and dropped.
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
eng := m.engine
ctx := m.ctx
since := time.Now().Add(-slaPeriods[periodIdx].duration)
return func() tea.Msg {
return slaDataMsg{
siteID: siteID,
periodIdx: periodIdx,
changes: eng.GetStateChangesSince(siteID, since),
changes: eng.GetStateChangesSince(ctx, siteID, since),
}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
}
result := sortSitesForDisplay(sites, nil)
result := sortSitesForDisplay(sites, nil, sortStatus, false)
if len(result) != 3 {
t.Fatalf("expected 3 sites, got %d", len(result))
}
@@ -34,7 +34,7 @@ func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
}
collapsed := map[int]bool{1: true}
result := sortSitesForDisplay(sites, collapsed)
result := sortSitesForDisplay(sites, collapsed, sortStatus, false)
if len(result) != 1 {
t.Fatalf("collapsed group should hide children, got %d items", len(result))
}
@@ -49,7 +49,7 @@ func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
{SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}},
{SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}},
}
result := sortSitesForDisplay(sites, nil)
result := sortSitesForDisplay(sites, nil, sortStatus, false)
if result[0].Status != "DOWN" {
t.Errorf("DOWN should sort first, got %s", result[0].Status)
}
+8 -8
View File
@@ -58,7 +58,7 @@ func classifyError(errorReason string, siteType string, statusCode int) ErrorCat
if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") {
return ErrCatHTTP
}
if statusCode >= 400 {
if statusCode >= httpErrorThreshold {
return ErrCatHTTP
}
@@ -145,7 +145,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
}
if detail == "" {
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTCP:
for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} {
@@ -155,7 +155,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
}
if detail == "" {
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTLS:
for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} {
@@ -166,16 +166,16 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
}
if detail == "" && strings.Contains(lower, "x509:") {
idx := strings.Index(lower, "x509:")
detail = limitStr(errorReason[idx:], 30)
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
}
if detail == "" {
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatHTTP:
if statusCode > 0 {
detail = fmt.Sprintf("HTTP %d", statusCode)
} else {
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTimeout:
if strings.Contains(lower, "i/o timeout") {
@@ -187,12 +187,12 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
if strings.Contains(lower, "no icmp response") {
detail = "no response"
} else {
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatPrivate:
detail = "private IP blocked"
default:
detail = limitStr(errorReason, 30)
detail = limitStr(errorReason, errorDetailMaxLen)
}
return detail
+32 -4
View File
@@ -49,13 +49,13 @@ func siteOrder(s models.Site) int {
return 3
}
switch s.Status {
case "DOWN", "SSL EXP":
case models.StatusDown, models.StatusSSLExp:
return 0
case "STALE":
case models.StatusStale:
return 1
case "LATE":
case models.StatusLate:
return 1
case "PENDING":
case models.StatusPending:
return 3
default:
return 2
@@ -104,6 +104,13 @@ func (m Model) fmtLatency(d time.Duration) string {
return m.st.dangerStyle.Render(s)
}
func (m Model) fmtUptimeMaint(statuses []bool, siteID int) string {
if m.isMonitorInMaintenance(siteID) {
return m.st.subtleStyle.Render("—")
}
return m.fmtUptime(statuses)
}
func (m Model) fmtUptime(statuses []bool) string {
if len(statuses) == 0 {
return m.st.subtleStyle.Render("—")
@@ -155,6 +162,27 @@ func (m Model) fmtRetries(site models.Site) string {
return s
}
func (m Model) fmtStatusDot(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇")
}
if inMaint {
return m.st.maintStyle.Render("◼")
}
switch status {
case models.StatusDown, models.StatusSSLExp:
return m.st.dangerStyle.Render("▼")
case models.StatusLate:
return m.st.warnStyle.Render("◆")
case models.StatusStale:
return m.st.staleStyle.Render("◆")
case models.StatusPending:
return m.st.subtleStyle.Render("○")
default:
return m.st.specialStyle.Render("▲")
}
}
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇ PAUSED")
+4 -2
View File
@@ -4,6 +4,7 @@ import (
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
)
// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes,
@@ -34,8 +35,9 @@ type tabDataMsg struct {
// on entry and refreshed on the tab-data cadence so View never touches the
// database.
type detailDataMsg struct {
siteID int
changes []models.StateChange
siteID int
changes []models.StateChange
dailyDays []monitor.DayReport
}
// historyDataMsg carries the full state-change history for the history view.
+135
View File
@@ -0,0 +1,135 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanelH(title, content, footer string, width, height, scrollOffset int, focused bool) string {
if height <= 0 {
return m.titledPanel(title, content, width, focused)
}
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)+"╮")
bottom := bc.Render("╰" + strings.Repeat("─", innerW) + "╯")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
contentLines := strings.Split(inner, "\n")
var footerLines []string
if footer != "" {
footerRendered := contentStyle.Render(footer)
footerLines = strings.Split(footerRendered, "\n")
}
bodyH := height - 2 - len(footerLines)
if bodyH < 1 {
bodyH = 1
}
if scrollOffset > len(contentLines)-bodyH {
scrollOffset = len(contentLines) - bodyH
}
if scrollOffset < 0 {
scrollOffset = 0
}
end := scrollOffset + bodyH
if end > len(contentLines) {
end = len(contentLines)
}
visible := contentLines[scrollOffset:end]
borderLine := func(line string) string {
return bc.Render("│") + line + strings.Repeat(" ", max(0, innerW-lipgloss.Width(line))) + bc.Render("│")
}
emptyLine := borderLine(strings.Repeat(" ", innerW))
var lines []string
lines = append(lines, top)
for _, line := range visible {
lines = append(lines, borderLine(line))
}
for len(lines) < height-1-len(footerLines) {
lines = append(lines, emptyLine)
}
for _, line := range footerLines {
lines = append(lines, borderLine(line))
}
lines = append(lines, bottom)
return strings.Join(lines, "\n")
}
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
}
func placeOverlay(fg string, termW, termH int) string {
return lipgloss.Place(termW, termH, lipgloss.Center, lipgloss.Center, fg)
}
+22 -10
View File
@@ -17,6 +17,17 @@ func parseHex(hex string) (r, g, b uint8) {
return
}
func trueColorHex(c lipgloss.TerminalColor) string {
switch v := c.(type) {
case lipgloss.CompleteColor:
return v.TrueColor
case lipgloss.Color:
return string(v)
default:
return ""
}
}
func dimColor(hex string, brightness float64) lipgloss.Color {
r, g, b := parseHex(hex)
f := 0.3 + brightness*0.7
@@ -27,35 +38,36 @@ func dimColor(hex string, brightness float64) lipgloss.Color {
))
}
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
if bg != "" {
func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style {
if bg != nil {
return s.Background(bg)
}
return s
}
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
var hex string
func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style {
var base lipgloss.TerminalColor
var t float64
switch {
case ms < 200:
hex = m.st.sparkSuccess
base = m.st.sparkSuccess
t = float64(ms) / 200
case ms < 500:
hex = m.st.sparkWarning
base = m.st.sparkWarning
t = float64(ms-200) / 300
default:
hex = m.st.sparkDanger
base = m.st.sparkDanger
t = float64(ms-500) / 1500
if t > 1 {
t = 1
}
}
hex := trueColorHex(base)
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
return withBg(s, bg)
}
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.TerminalColor) string {
if len(latencies) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
}
@@ -103,7 +115,7 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
return sb.String()
}
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.TerminalColor) string {
if len(statuses) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
}
@@ -143,7 +155,7 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
return offset + (x - padding)
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
+19 -17
View File
@@ -5,10 +5,12 @@ import (
"testing"
"time"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
func TestLatencySparkline_Empty(t *testing.T) {
got := styledModel.latencySparkline(nil, nil, 10, "")
got := styledModel.latencySparkline(nil, nil, 10, nil)
if !strings.Contains(got, "··········") {
t.Errorf("empty sparkline should be dots, got %q", got)
}
@@ -17,7 +19,7 @@ func TestLatencySparkline_Empty(t *testing.T) {
func TestLatencySparkline_SingleValue(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond}
statuses := []bool{true}
got := styledModel.latencySparkline(latencies, statuses, 5, "")
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
if len(got) == 0 {
t.Error("sparkline should not be empty")
}
@@ -33,7 +35,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
latencies[i] = time.Duration(i*50) * time.Millisecond
statuses[i] = true
}
got := styledModel.latencySparkline(latencies, statuses, 5, "")
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
if len(got) == 0 {
t.Error("sparkline should not be empty")
}
@@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
func TestLatencySparkline_RelativeHeight(t *testing.T) {
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
statuses := []bool{true, true, true}
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil))
runes := []rune(out)
if len(runes) < 3 {
t.Fatalf("expected 3 runes, got %d", len(runes))
@@ -57,14 +59,14 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) {
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00"
st.sparkWarning = "#ffff00"
st.sparkDanger = "#ff0000"
st.sparkSuccess = lipgloss.Color("#00ff00")
st.sparkWarning = lipgloss.Color("#ffff00")
st.sparkDanger = lipgloss.Color("#ff0000")
m := Model{st: st}
green := m.latencyStyle(50, "")
yellow := m.latencyStyle(300, "")
red := m.latencyStyle(800, "")
green := m.latencyStyle(50, nil)
yellow := m.latencyStyle(300, nil)
red := m.latencyStyle(800, nil)
gfg := green.GetForeground()
yfg := yellow.GetForeground()
@@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00"
st.sparkSuccess = lipgloss.Color("#00ff00")
m := Model{st: st}
dim := m.latencyStyle(10, "")
bright := m.latencyStyle(190, "")
dim := m.latencyStyle(10, nil)
bright := m.latencyStyle(190, nil)
if dim.GetForeground() == bright.GetForeground() {
t.Error("10ms and 190ms should have different brightness within green band")
@@ -91,7 +93,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
func TestLatencySparkline_OutputWidth(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
statuses := []bool{true, true, true}
got := styledModel.latencySparkline(latencies, statuses, 5, "")
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
count := utf8.RuneCountInString(stripANSI(got))
if count != 5 {
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
@@ -116,7 +118,7 @@ func stripANSI(s string) string {
}
func TestHeartbeatSparkline_Empty(t *testing.T) {
got := styledModel.heartbeatSparkline(nil, 10, "")
got := styledModel.heartbeatSparkline(nil, 10, nil)
if !strings.Contains(got, "··········") {
t.Errorf("empty heartbeat should be dots, got %q", got)
}
@@ -124,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
func TestHeartbeatSparkline_Mixed(t *testing.T) {
statuses := []bool{true, false, true, true, false}
got := styledModel.heartbeatSparkline(statuses, 5, "")
got := styledModel.heartbeatSparkline(statuses, 5, nil)
if len(got) == 0 {
t.Error("heartbeat sparkline should not be empty")
}
@@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
statuses := []bool{true, true}
got := styledModel.heartbeatSparkline(statuses, 5, "")
got := styledModel.heartbeatSparkline(statuses, 5, nil)
if !strings.Contains(got, "···") {
t.Errorf("should have dot padding for width > data, got %q", got)
}
+102 -9
View File
@@ -1,7 +1,6 @@
package tui
import (
"context"
"fmt"
neturl "net/url"
"sort"
@@ -185,7 +184,7 @@ func (m Model) viewAlertsTab() string {
nameW := widths[2]
cfgW := widths[4]
return m.renderTable(
tbl := m.renderTable(
headers,
len(m.alerts),
func(start, end int) [][]string {
@@ -206,13 +205,101 @@ func (m Model) viewAlertsTab() string {
},
widths, nil,
)
var totalSent, totalFail int
for _, a := range m.alerts {
h := m.engine.GetAlertHealth(a.ID)
totalSent += h.SendCount
totalFail += h.FailCount
}
types := make(map[string]bool)
for _, a := range m.alerts {
types[a.Type] = true
}
failLabel := "failures"
if totalFail == 1 {
failLabel = "failure"
}
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
len(m.alerts), len(types), totalSent, totalFail, failLabel)
var detail string
if m.cursor < len(m.alerts) {
a := m.alerts[m.cursor]
h := m.engine.GetAlertHealth(a.ID)
detail = m.alertSelectedDetail(a, h)
}
return tbl + "\n " + detail + "\n " + m.st.subtleStyle.Render(summary)
}
func (m Model) alertSelectedDetail(a models.AlertConfig, h monitor.AlertHealth) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
parts := []string{fmtAlertType(a.Type)}
parts = append(parts, m.fmtAlertConfigFull(a))
if !h.LastSendAt.IsZero() {
if h.LastSendOK {
parts = append(parts, m.st.specialStyle.Render("●")+" "+label.Render("sent")+" "+m.fmtTimeAgo(h.LastSendAt))
} else {
parts = append(parts, m.st.dangerStyle.Render("●")+" "+label.Render("failed")+" "+m.fmtTimeAgo(h.LastSendAt))
}
}
if h.SendCount > 0 {
parts = append(parts, label.Render(fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)))
}
return strings.Join(parts, dot)
}
func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
switch alert.Type {
case "email":
host := alert.Settings["host"]
to := alert.Settings["to"]
if host != "" && to != "" {
return fmt.Sprintf("%s → %s", host, to)
}
if host != "" {
return host
}
return m.st.subtleStyle.Render("—")
case "ntfy":
topic := alert.Settings["topic"]
url := alert.Settings["url"]
if url != "" && topic != "" {
return fmt.Sprintf("%s/%s", url, topic)
}
return m.st.subtleStyle.Render("—")
case "telegram":
if id := alert.Settings["chat_id"]; id != "" {
return fmt.Sprintf("chat:%s", id)
}
return m.st.subtleStyle.Render("—")
case "gotify":
if url := alert.Settings["url"]; url != "" {
return url
}
return m.st.subtleStyle.Render("—")
default:
if val, ok := alert.Settings["url"]; ok && val != "" {
return maskWebhookURL(val)
}
return m.st.subtleStyle.Render("—")
}
}
func (m Model) viewAlertDetailPanel() string {
if m.cursor >= len(m.alerts) {
idx := m.cursor
if m.returnState == stateSettings {
idx = m.settingsCursor
}
if idx >= len(m.alerts) {
return ""
}
a := m.alerts[m.cursor]
a := m.alerts[idx]
h := m.engine.GetAlertHealth(a.ID)
var b strings.Builder
@@ -245,7 +332,7 @@ func (m Model) viewAlertDetailPanel() string {
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
b.WriteString(m.st.titleStyle.Render(" CONFIGURATION") + "\n")
// Render through the same allowlist the backup export uses — this panel
// ends up in screen shares and asciinema recordings. Keys are sorted so
// rows don't reshuffle every render.
@@ -265,7 +352,7 @@ func (m Model) viewAlertDetailPanel() string {
}
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test"))
b.WriteString(m.st.subtleStyle.Render(" [e] Edit [t] Test [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
@@ -518,15 +605,21 @@ func (m *Model) submitAlertForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
id := m.editID
name, aType := d.Name, d.AlertType
m.state = stateDashboard
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(context.Background(), id, name, aType, settings)
return st.UpdateAlert(ctx, id, name, aType, settings)
})
}
return writeCmd("Add alert", func() error {
return st.AddAlert(context.Background(), name, aType, settings)
return st.AddAlert(ctx, name, aType, settings)
})
}
+30 -45
View File
@@ -3,6 +3,9 @@ package tui
import (
"fmt"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type logSeverity int
@@ -15,8 +18,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,67 +66,49 @@ 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)
}
func (m Model) viewLogsFullscreen() string {
header := " " + m.st.titleStyle.Render("Logs") + "\n" + m.divider()
filterLabel := m.st.subtleStyle.Render("[f] All")
if m.logFilterImportant {
filterLabel = m.st.subtleStyle.Render("[f] Important only")
}
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
footer := m.divider() + "\n " + filterLabel + " " + countLabel + " " + m.st.subtleStyle.Render("[q/Esc] Back")
m.logViewport.Width = m.termWidth - chromePadH
m.logViewport.Height = m.termHeight - 8
return lipgloss.NewStyle().Padding(1, 2).Render(
header + "\n" + m.logViewport.View() + "\n" + footer)
}
// 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
m.logShown = shown
m.logViewport.SetContent(strings.Join(rendered, "\n"))
}
func (m Model) viewLogsTab() string {
if m.logTotal == 0 {
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
}
filterLabel := "All"
if m.logFilterImportant {
filterLabel = "Important"
}
header := m.st.subtleStyle.Render(fmt.Sprintf(
" %d entries Filter: %s", m.logShown, filterLabel))
if m.logFilterImportant && m.logShown < m.logTotal {
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown))
}
return "\n" + header + "\n\n" + m.logViewport.View()
}
+98
View File
@@ -0,0 +1,98 @@
package tui
import (
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
sev := classifyLog(entry.Message)
var tag string
switch sev {
case severityDown:
tag = m.st.dangerStyle.Render("▼")
case severityUp:
tag = m.st.specialStyle.Render("▲")
case severityWarn:
tag = m.st.warnStyle.Render("◆")
case severitySystem:
tag = m.st.titleStyle.Render("●")
default:
tag = m.st.subtleStyle.Render("·")
}
ts := entry.CreatedAt.Local().Format("01/02 15:04")
msg := entry.Message
msg = strings.TrimPrefix(msg, "Monitor ")
msg = strings.TrimPrefix(msg, "Push ")
prefixW := len(ts) + 4
msgW := maxW - prefixW
if msgW < 5 {
msgW = 5
}
msg = limitStr(msg, msgW)
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
}
func (m Model) viewLogsStrip(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet")
}
style := lipgloss.NewStyle().Width(width).MaxWidth(width)
var all []string
for _, entry := range logs {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue
}
all = append(all, m.renderCompactLogLine(entry, width))
}
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 style.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
}
}
+6 -89
View File
@@ -1,7 +1,6 @@
package tui
import (
"context"
"fmt"
"strconv"
"time"
@@ -21,50 +20,6 @@ type maintFormData struct {
CustomHours string
}
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now()
if mw.StartTime.After(now) {
return m.st.warnStyle.Render("SCHEDULED")
}
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
return m.st.subtleStyle.Render("ENDED")
}
return m.st.specialStyle.Render("ACTIVE")
}
func (m Model) fmtMaintType(t string) string {
if t == "incident" {
return m.st.dangerStyle.Render("incident")
}
return m.st.maintStyle.Render("maintenance")
}
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
if monitorID == 0 {
return "All"
}
for _, s := range sites {
if s.ID == monitorID {
return limitStr(s.Name, maxW)
}
}
return fmt.Sprintf("#%d", monitorID)
}
func (m Model) fmtMaintTime(t time.Time, colW int) string {
if t.IsZero() {
return m.st.subtleStyle.Render("—")
}
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
if colW >= 14 {
return t.Format("15:04 Jan 02")
}
return t.Format("Jan 02")
}
func (m Model) isMonitorInMaintenance(monitorID int) bool {
for _, mw := range m.maintenanceWindows {
if mw.Type != "maintenance" {
@@ -89,49 +44,6 @@ func (m Model) isMonitorInMaintenance(monitorID int) bool {
return false
}
func (m Model) viewMaintTab() string {
if len(m.maintenanceWindows) == 0 {
return m.emptyState("No maintenance windows or incidents.", "[n] Create one")
}
var headers []string
var widths []int
if m.isWide() {
headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}
widths = []int{4, 24, 14, 22, 12, 16, 16}
} else {
headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"}
widths = []int{4, 14, 13, 14, 11, 14, 14}
}
titleW := widths[1]
monW := widths[3]
timeW := widths[5]
return m.renderTable(
headers,
len(m.maintenanceWindows),
func(start, end int) [][]string {
var rows [][]string
allSites := m.engine.GetAllSites()
for i := start; i < end; i++ {
mw := m.maintenanceWindows[i]
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)),
m.fmtMaintType(mw.Type),
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
m.fmtMaintStatus(mw),
m.fmtMaintTime(mw.StartTime, timeW),
m.fmtMaintTime(mw.EndTime, timeW),
})
}
return rows
},
widths,
nil,
)
}
func (m *Model) initMaintHuhForm() tea.Cmd {
m.maintFormData = &maintFormData{
Type: "maintenance",
@@ -239,8 +151,13 @@ func (m *Model) submitMaintForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
m.state = stateDashboard
return writeCmd("Add maintenance window", func() error {
return st.AddMaintenanceWindow(context.Background(), mw)
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
if len(overlaps) > 0 {
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
}
return st.AddMaintenanceWindow(ctx, mw)
})
}
+36 -10
View File
@@ -1,26 +1,28 @@
package tui
import (
"fmt"
"strings"
"time"
)
func (m Model) viewNodesTab() string {
if len(m.nodes) == 0 {
return m.emptyState("No probe nodes connected.", "")
return m.emptyState("No probe nodes connected.", "Probes auto-register on connection.")
}
var headers []string
var widths []int
if m.isWide() {
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{24, 14, 16, 12, 10}
headers = []string{"#", "NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
widths = []int{4, 24, 14, 16, 12, 10}
} else {
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{16, 10, 10, 8, 8}
headers = []string{"#", "NAME", "REGION", "SEEN", "VER", "STATUS"}
widths = []int{4, 16, 10, 10, 8, 8}
}
nameW := widths[0]
nameW := widths[1]
return m.renderTable(
tbl := m.renderTable(
headers,
len(m.nodes),
func(start, end int) [][]string {
@@ -41,13 +43,37 @@ func (m Model) viewNodesTab() string {
version = m.st.subtleStyle.Render("—")
}
status := m.fmtNodeStatus(node.LastSeen)
rows = append(rows, []string{name, region, lastSeen, version, status})
rows = append(rows, []string{fmt.Sprintf("%d", i+1), name, region, lastSeen, version, status})
}
return rows
},
widths,
nil,
)
var online int
regions := make(map[string]bool)
var leader string
for _, n := range m.nodes {
if time.Since(n.LastSeen) < nodeOnlineThreshold {
online++
}
if n.Region != "" {
regions[n.Region] = true
}
if n.Name == "leader" {
leader = n.ID
}
}
parts := []string{fmt.Sprintf("%d/%d online", online, len(m.nodes))}
if leader != "" {
parts = append(parts, fmt.Sprintf("leader: %s", leader))
}
if len(regions) > 0 {
parts = append(parts, fmt.Sprintf("%d regions", len(regions)))
}
return tbl + "\n " + m.st.subtleStyle.Render(strings.Join(parts, " · "))
}
func (m Model) fmtNodeStatus(lastSeen time.Time) string {
@@ -55,10 +81,10 @@ func (m Model) fmtNodeStatus(lastSeen time.Time) string {
return m.st.subtleStyle.Render("UNKNOWN")
}
ago := time.Since(lastSeen)
if ago < 60*time.Second {
if ago < nodeOnlineThreshold {
return m.st.specialStyle.Render("ONLINE")
}
if ago < 5*time.Minute {
if ago < nodeStaleThreshold {
return m.st.warnStyle.Render("STALE")
}
return m.st.dangerStyle.Render("OFFLINE")
+56
View File
@@ -0,0 +1,56 @@
package tui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewSettingsTab() string {
sections := []string{"Alerts", "Nodes"}
if m.isAdmin {
sections = append(sections, "Users")
}
var tabs []string
for i, name := range sections {
var rendered string
if i == m.settingsSection {
rendered = m.st.activeTab.Render(name)
} else {
rendered = m.st.inactiveTab.Render(name)
}
tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered))
}
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
var content string
switch m.settingsSection {
case sectionAlerts:
content = m.viewAlertsTab()
case sectionNodes:
content = m.viewNodesTab()
case sectionUsers:
if m.isAdmin {
content = m.viewUsersTab()
}
}
return header + "\n" + content
}
func (m *Model) switchSettingsSection(idx int) {
max := 1
if m.isAdmin {
max = 2
}
if idx > max {
idx = 0
}
if idx < 0 {
idx = max
}
m.settingsSection = idx
m.cursor = 0
m.tableOffset = 0
}
+87 -21
View File
@@ -1,7 +1,6 @@
package tui
import (
"context"
"fmt"
"net/url"
"strconv"
@@ -35,10 +34,9 @@ type siteFormData struct {
type colKey int
const (
colNum colKey = iota
colDot colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
@@ -56,17 +54,18 @@ type columnDef struct {
}
var siteColumns = []columnDef{
{colNum, "#", "#", 4, 4, 0},
{colDot, "", "", 3, 3, 0},
{colName, "NAME", "NAME", 0, 0, 0},
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
{colStatus, "STATUS", "STATUS", 10, 10, 0},
{colType, "TYPE", "TYPE", 10, 8, 0},
{colLatency, "LATENCY", "LAT", 10, 7, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
{colUptime, "UPTIME", "UP%", 8, 8, 0},
{colHistory, "HISTORY", "HISTORY", 0, 0, 0},
{colSSL, "SSL", "SSL", 7, 5, 0},
{colRetries, "RETRIES", "RT", 9, 5, 0},
}
var columnDropOrder = []colKey{colHistory, colRetries, colSSL, colUptime, colType}
type tableLayout struct {
nameW, sparkW int
headers []string
@@ -77,13 +76,51 @@ type tableLayout struct {
func (m Model) computeLayout() tableLayout {
wide := m.isWide()
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
dropped := make(map[colKey]bool)
minNameW := 20
minSparkW := 12
for attempt := 0; attempt <= len(columnDropOrder); attempt++ {
var fixed int
var flexCount int
for _, c := range siteColumns {
if dropped[c.key] {
continue
}
w := c.narrowW
if wide {
w = c.wideW
}
if w > 0 {
fixed += w
} else {
flexCount++
}
}
numCols := len(siteColumns) - len(dropped)
borderOverhead := 2 + (numCols - 1)
avail := cw - chromePadH - 2 - borderOverhead - fixed
minFlex := minNameW
if flexCount > 1 {
minFlex += minSparkW
}
if avail >= minFlex || attempt >= len(columnDropOrder) {
break
}
dropped[columnDropOrder[attempt]] = true
}
var active []colKey
var headers []string
var widths []int
var fixed int
for _, c := range siteColumns {
if c.minTerm > 0 && m.termWidth < c.minTerm {
if dropped[c.key] {
continue
}
active = append(active, c.key)
@@ -102,9 +139,37 @@ func (m Model) computeLayout() tableLayout {
}
}
sortColMap := map[int]colKey{
sortStatus: colDot,
sortName: colName,
sortLatency: colLatency,
}
sortableKeys := map[colKey]string{
colDot: "sort-status",
colName: "sort-name",
colLatency: "sort-latency",
}
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
arrow := "▼"
if m.sortAsc {
arrow = "▲"
}
for i, k := range active {
if k == sortedKey {
headers[i] = headers[i] + arrow
break
}
}
}
for i, k := range active {
if zoneID, ok := sortableKeys[k]; ok {
headers[i] = m.zones.Mark(zoneID, headers[i])
}
}
numCols := len(headers)
borderOverhead := 2 + (numCols - 1)
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed
avail := cw - chromePadH - 2 - borderOverhead - fixed
if avail < 20 {
avail = 20
}
@@ -204,7 +269,7 @@ func (m Model) viewSitesTab() string {
for i := start; i < end; i++ {
site := m.sites[i]
rowIdx := i - start
var rowBg lipgloss.Color
var rowBg lipgloss.TerminalColor
if i == m.cursor {
rowBg = m.theme.SelectedBg
} else if rowIdx%2 == 1 {
@@ -214,11 +279,11 @@ func (m Model) viewSitesTab() string {
if site.Type == "group" {
groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID])
inMaint := m.isMonitorInMaintenance(site.ID)
cells := map[colKey]string{
colNum: strconv.Itoa(i + 1),
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
colType: "group",
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.st.subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg),
@@ -262,13 +327,13 @@ func (m Model) viewSitesTab() string {
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg)
}
inMaint := m.isMonitorInMaintenance(site.ID)
cells := map[colKey]string{
colNum: strconv.Itoa(i + 1),
colDot: m.fmtStatusDot(site.Status, site.Paused, inMaint),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name),
colType: typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
colLatency: m.fmtLatency(site.Latency),
colUptime: m.fmtUptime(hist.Statuses),
colUptime: m.fmtUptimeMaint(hist.Statuses, site.ID),
colHistory: spark,
colSSL: m.fmtSSL(site),
colRetries: m.fmtRetries(site),
@@ -582,10 +647,11 @@ func (m *Model) submitSiteForm() tea.Cmd {
}
st := m.store
ctx := m.ctx
m.state = stateDashboard
if m.editID > 0 {
m.engine.UpdateSiteConfig(cfg)
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) })
return writeCmd("Update site", func() error { return st.UpdateSite(ctx, cfg) })
}
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) })
return writeCmd("Add site", func() error { return st.AddSite(ctx, cfg) })
}
+9 -4
View File
@@ -1,7 +1,6 @@
package tui
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
@@ -114,15 +113,21 @@ func (m *Model) initUserHuhForm() tea.Cmd {
func (m *Model) submitUserForm() tea.Cmd {
d := m.userFormData
st := m.store
ctx := m.ctx
id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role
m.state = stateUsers
if m.returnState == stateSettings {
m.state = stateSettings
} else {
m.state = stateDashboard
}
m.returnState = 0
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(context.Background(), id, username, key, role)
return st.UpdateUser(ctx, id, username, key, role)
})
}
return writeCmd("Add user", func() error {
return st.AddUser(context.Background(), username, key, role)
return st.AddUser(ctx, username, key, role)
})
}
+12 -5
View File
@@ -13,7 +13,11 @@ const (
)
func (m Model) isWide() bool {
return m.termWidth >= wideBreakpoint
w := m.contentWidth
if w == 0 {
w = m.termWidth
}
return w >= wideBreakpoint
}
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
@@ -35,7 +39,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
}
borderOverhead := 2 + len(colWidths) - 1
tableWidth := colTotal + borderOverhead
maxWidth := m.termWidth - chromePadH - 2
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
maxWidth := cw - chromePadH - 2
if tableWidth > maxWidth {
tableWidth = maxWidth
}
@@ -44,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...).
@@ -86,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return base
})
return "\n" + t.Render()
return t.Render()
}
+110 -102
View File
@@ -5,35 +5,43 @@ import (
"github.com/charmbracelet/lipgloss"
)
func cc(hex, ansi string) lipgloss.CompleteColor {
return lipgloss.CompleteColor{
TrueColor: hex,
ANSI256: hex,
ANSI: ansi,
}
}
type Theme struct {
Name string
// Base layers
Bg lipgloss.Color
Surface lipgloss.Color
Panel lipgloss.Color
Border lipgloss.Color
Bg lipgloss.TerminalColor
Surface lipgloss.TerminalColor
Panel lipgloss.TerminalColor
Border lipgloss.TerminalColor
// Text
Fg lipgloss.Color
Muted lipgloss.Color
Subtle lipgloss.Color
Fg lipgloss.TerminalColor
Muted lipgloss.TerminalColor
Subtle lipgloss.TerminalColor
// Semantic
Success lipgloss.Color
Warning lipgloss.Color
Stale lipgloss.Color
Danger lipgloss.Color
Info lipgloss.Color
Accent lipgloss.Color
Purple lipgloss.Color
Success lipgloss.TerminalColor
Warning lipgloss.TerminalColor
Stale lipgloss.TerminalColor
Danger lipgloss.TerminalColor
Info lipgloss.TerminalColor
Accent lipgloss.TerminalColor
Purple lipgloss.TerminalColor
// Table
ZebraBg lipgloss.Color
ZebraBg lipgloss.TerminalColor
// Selection
SelectedFg lipgloss.Color
SelectedBg lipgloss.Color
SelectedFg lipgloss.TerminalColor
SelectedBg lipgloss.TerminalColor
}
var themes = []Theme{
@@ -46,107 +54,107 @@ var themes = []Theme{
var themeFlexokiDark = Theme{
Name: "Flexoki Dark",
Bg: "#1C1B1A",
Surface: "#282726",
Panel: "#343331",
Border: "#575653",
Fg: "#CECDC3",
Muted: "#878580",
Subtle: "#6F6E69",
Success: "#879A39",
Warning: "#D0A215",
Stale: "#DA702C",
Danger: "#D14D41",
Info: "#4385BE",
Accent: "#3AA99F",
Purple: "#8B7EC8",
ZebraBg: "#222120",
SelectedFg: "#FFFCF0",
SelectedBg: "#403E3C",
Bg: cc("#1C1B1A", ""),
Surface: cc("#282726", ""),
Panel: cc("#343331", ""),
Border: cc("#575653", "8"),
Fg: cc("#CECDC3", "15"),
Muted: cc("#878580", "7"),
Subtle: cc("#6F6E69", "7"),
Success: cc("#879A39", "10"),
Warning: cc("#D0A215", "11"),
Stale: cc("#DA702C", "3"),
Danger: cc("#D14D41", "9"),
Info: cc("#4385BE", "12"),
Accent: cc("#3AA99F", "14"),
Purple: cc("#8B7EC8", "13"),
ZebraBg: cc("#222120", ""),
SelectedFg: cc("#FFFCF0", "15"),
SelectedBg: cc("#403E3C", "4"),
}
var themeTokyoNight = Theme{
Name: "Tokyo Night",
Bg: "#1a1b26",
Surface: "#24283b",
Panel: "#292e42",
Border: "#3b4261",
Fg: "#c0caf5",
Muted: "#a9b1d6",
Subtle: "#565f89",
Success: "#9ece6a",
Warning: "#e0af68",
Stale: "#ff9e64",
Danger: "#f7768e",
Info: "#7aa2f7",
Accent: "#7dcfff",
Purple: "#bb9af7",
ZebraBg: "#1c1d28",
SelectedFg: "#c0caf5",
SelectedBg: "#292e42",
Bg: cc("#1a1b26", ""),
Surface: cc("#24283b", ""),
Panel: cc("#292e42", ""),
Border: cc("#3b4261", "8"),
Fg: cc("#c0caf5", "15"),
Muted: cc("#a9b1d6", "7"),
Subtle: cc("#565f89", "7"),
Success: cc("#9ece6a", "10"),
Warning: cc("#e0af68", "11"),
Stale: cc("#ff9e64", "3"),
Danger: cc("#f7768e", "9"),
Info: cc("#7aa2f7", "12"),
Accent: cc("#7dcfff", "14"),
Purple: cc("#bb9af7", "13"),
ZebraBg: cc("#1c1d28", ""),
SelectedFg: cc("#c0caf5", "15"),
SelectedBg: cc("#292e42", "4"),
}
var themeGruvbox = Theme{
Name: "Gruvbox",
Bg: "#282828",
Surface: "#3c3836",
Panel: "#504945",
Border: "#665c54",
Fg: "#ebdbb2",
Muted: "#bdae93",
Subtle: "#7c6f64",
Success: "#b8bb26",
Warning: "#fabd2f",
Stale: "#fe8019",
Danger: "#fb4934",
Info: "#83a598",
Accent: "#8ec07c",
Purple: "#d3869b",
ZebraBg: "#2a2a2a",
SelectedFg: "#fbf1c7",
SelectedBg: "#504945",
Bg: cc("#282828", ""),
Surface: cc("#3c3836", ""),
Panel: cc("#504945", ""),
Border: cc("#665c54", "8"),
Fg: cc("#ebdbb2", "15"),
Muted: cc("#bdae93", "7"),
Subtle: cc("#7c6f64", "7"),
Success: cc("#b8bb26", "10"),
Warning: cc("#fabd2f", "11"),
Stale: cc("#fe8019", "3"),
Danger: cc("#fb4934", "9"),
Info: cc("#83a598", "12"),
Accent: cc("#8ec07c", "14"),
Purple: cc("#d3869b", "13"),
ZebraBg: cc("#2a2a2a", ""),
SelectedFg: cc("#fbf1c7", "15"),
SelectedBg: cc("#504945", "4"),
}
var themeCatppuccinMocha = Theme{
Name: "Catppuccin Mocha",
Bg: "#1e1e2e",
Surface: "#313244",
Panel: "#45475a",
Border: "#585b70",
Fg: "#cdd6f4",
Muted: "#a6adc8",
Subtle: "#6c7086",
Success: "#a6e3a1",
Warning: "#f9e2af",
Stale: "#fab387",
Danger: "#f38ba8",
Info: "#89b4fa",
Accent: "#94e2d5",
Purple: "#cba6f7",
ZebraBg: "#232334",
SelectedFg: "#cdd6f4",
SelectedBg: "#45475a",
Bg: cc("#1e1e2e", ""),
Surface: cc("#313244", ""),
Panel: cc("#45475a", ""),
Border: cc("#585b70", "8"),
Fg: cc("#cdd6f4", "15"),
Muted: cc("#a6adc8", "7"),
Subtle: cc("#6c7086", "7"),
Success: cc("#a6e3a1", "10"),
Warning: cc("#f9e2af", "11"),
Stale: cc("#fab387", "3"),
Danger: cc("#f38ba8", "9"),
Info: cc("#89b4fa", "12"),
Accent: cc("#94e2d5", "14"),
Purple: cc("#cba6f7", "13"),
ZebraBg: cc("#232334", ""),
SelectedFg: cc("#cdd6f4", "15"),
SelectedBg: cc("#45475a", "4"),
}
var themeNord = Theme{
Name: "Nord",
Bg: "#2e3440",
Surface: "#3b4252",
Panel: "#434c5e",
Border: "#4c566a",
Fg: "#d8dee9",
Muted: "#d8dee9",
Subtle: "#4c566a",
Success: "#a3be8c",
Warning: "#ebcb8b",
Stale: "#d08770",
Danger: "#bf616a",
Info: "#81a1c1",
Accent: "#88c0d0",
Purple: "#b48ead",
ZebraBg: "#323845",
SelectedFg: "#eceff4",
SelectedBg: "#434c5e",
Bg: cc("#2e3440", ""),
Surface: cc("#3b4252", ""),
Panel: cc("#434c5e", ""),
Border: cc("#4c566a", "8"),
Fg: cc("#d8dee9", "15"),
Muted: cc("#d8dee9", "7"),
Subtle: cc("#4c566a", "7"),
Success: cc("#a3be8c", "10"),
Warning: cc("#ebcb8b", "11"),
Stale: cc("#d08770", "3"),
Danger: cc("#bf616a", "9"),
Info: cc("#81a1c1", "12"),
Accent: cc("#88c0d0", "14"),
Purple: cc("#b48ead", "13"),
ZebraBg: cc("#323845", ""),
SelectedFg: cc("#eceff4", "15"),
SelectedBg: cc("#434c5e", "4"),
}
func (t Theme) HuhTheme() *huh.Theme {
+110 -64
View File
@@ -30,9 +30,9 @@ type styles struct {
activeTab lipgloss.Style
inactiveTab lipgloss.Style
sparkSuccess string
sparkWarning string
sparkDanger string
sparkSuccess lipgloss.TerminalColor
sparkWarning lipgloss.TerminalColor
sparkDanger lipgloss.TerminalColor
tableHeaderStyle lipgloss.Style
tableCellStyle lipgloss.Style
@@ -46,23 +46,23 @@ type styles struct {
func newStyles(t Theme) *styles {
return &styles{
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle),
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
warnStyle: lipgloss.NewStyle().Foreground(t.Warning),
staleStyle: lipgloss.NewStyle().Foreground(t.Stale),
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger),
warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted),
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted).Faint(true),
sparkSuccess: string(t.Success),
sparkWarning: string(t.Warning),
sparkDanger: string(t.Danger),
sparkSuccess: t.Success,
sparkWarning: t.Warning,
sparkDanger: t.Danger,
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border),
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border).Faint(true),
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
@@ -80,38 +80,77 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
)
detailSparkWidth = 40
const (
sectionAlerts = 0
sectionNodes = 1
sectionUsers = 2
)
const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
panelMaint = 3
)
type bottomPanel int
const (
bottomNone bottomPanel = iota
bottomLogs
bottomMaint
)
const (
detailDefault = 0
detailSLA = 1
detailHistory = 2
)
const (
sortStatus = 0
sortName = 1
sortLatency = 2
sortMax = 3
)
type sessionState int
const (
stateDashboard sessionState = iota
stateLogs
stateUsers
stateDetail
stateAlertDetail
stateFormSite
stateFormAlert
stateFormUser
stateConfirmDelete
stateFormMaint
stateHistory
stateSLA
stateDashboard sessionState = 0
stateLogs sessionState = 1
stateAlertDetail sessionState = 3
stateFormSite sessionState = 4
stateFormAlert sessionState = 5
stateFormUser sessionState = 6
stateConfirmDelete sessionState = 7
stateFormMaint sessionState = 8
stateSettings sessionState = 11
stateMaintDetail sessionState = 12
)
type Model struct {
state sessionState
currentTab int
cursor int
selectedID int
tableOffset int
maxTableRows int
termWidth int
termHeight int
editID int
editToken string
state sessionState
returnState sessionState
settingsSection int
settingsCursor int
settingsOffset int
cursor int
selectedID int
sortColumn int
sortAsc bool
tableOffset int
maxTableRows int
termWidth int
termHeight int
contentWidth int
focusedPanel int
detailMode int
logScrollOffset int
editID int
editToken string
huhForm *huh.Form
siteFormData *siteFormData
@@ -125,25 +164,27 @@ type Model struct {
logTotal int
logShown int
historyViewport viewport.Model
historyChanges []models.StateChange
historySiteName string
historySiteID int
slaViewport viewport.Model
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
slaReport monitor.SLAReport
slaDailyBreakdown []monitor.DayReport
slaSiteName string
slaSiteID int
slaPeriodIdx int
detailScrollOffset int
isAdmin bool
zones *zone.Manager
deleteID int
deleteName string
deleteTab int
deleteKind string
maintDetailID int
ctx context.Context
collapsed map[int]bool
store store.Store
engine *monitor.Engine
@@ -165,29 +206,30 @@ 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
bottomPanel bottomPanel
detailOpen bool
maintCursor int
detailChanges []models.StateChange
detailChangesSiteID int
detailDailyDays []monitor.DayReport
filterMode bool
filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
// demoMode renders a stable status dot instead of the animated pulse so
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
demoMode bool
version string
}
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...")
z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(s)
collapsed := loadCollapsed(ctx, s)
themeName, _ := s.GetPreference(context.Background(), "theme")
themeName, _ := s.GetPreference(ctx, "theme")
theme := themeByName(themeName)
themeIdx := 0
for i, t := range themes {
@@ -197,22 +239,26 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
}
}
detailPref, _ := s.GetPreference(ctx, "detail_open")
return Model{
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
ctx: ctx,
state: stateDashboard,
logViewport: vpLogs,
maxTableRows: 5,
isAdmin: isAdmin,
store: s,
engine: eng,
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
bottomPanel: bottomLogs,
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
}
}
+488 -378
View File
File diff suppressed because it is too large Load Diff
+13 -22
View File
@@ -118,11 +118,10 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.state = stateDetail
m.detailOpen = true
m.termWidth = 120
m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1)
if cmd == nil {
t.Fatal("loadDetailCmd returned nil")
@@ -136,16 +135,14 @@ func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
}
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
}
// Render the detail panel several times — it must read the cache, not the DB.
for i := 0; i < 3; i++ {
_ = m.viewDetailPanel()
_ = m.viewDetailInline(80, 30)
}
if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
@@ -202,16 +199,16 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.state = stateDetail
m.detailOpen = true
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
updated, cmd := (&m).handleDashboardKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
if got.detailMode != detailHistory || got.historySiteID != 7 {
t.Fatalf("history mode not set: mode=%v siteID=%d", got.detailMode, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
@@ -229,7 +226,6 @@ func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
t.Fatal("history reply not folded into the model")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
@@ -241,20 +237,15 @@ func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
m.detailOpen = true
m.slaSiteID = 3
m.slaPeriodIdx = 2
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
@@ -266,7 +257,7 @@ func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete
m.deleteTab = 0
m.deleteKind = "site"
m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
@@ -300,7 +291,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
}
}
@@ -314,12 +305,12 @@ func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDashboard
m.detailOpen = false
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.state = stateDetail
m.detailOpen = true
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
+50
View File
@@ -0,0 +1,50 @@
package tui
import (
"fmt"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
)
func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
if len(days) == 0 {
return m.st.subtleStyle.Render("No uptime data")
}
maxDays := width - 10
if maxDays < 10 {
maxDays = 10
}
display := days
if len(display) > maxDays {
display = display[len(display)-maxDays:]
}
var sb strings.Builder
for _, d := range display {
ch := "█"
switch {
case d.UptimePct >= uptimeGoodPct:
sb.WriteString(m.st.specialStyle.Render(ch))
case d.UptimePct >= uptimeWarnPct:
sb.WriteString(m.st.warnStyle.Render(ch))
case d.UptimePct > 0:
sb.WriteString(m.st.dangerStyle.Render(ch))
default:
sb.WriteString(m.st.subtleStyle.Render("░"))
}
}
pct := days[len(days)-1].UptimePct
pctStyle := m.st.specialStyle
if pct < uptimeGoodPct {
pctStyle = m.st.dangerStyle
} else if pct < uptimeExcellentPct {
pctStyle = m.st.warnStyle
}
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
return sb.String()
}
+136 -105
View File
@@ -47,12 +47,12 @@ func (m Model) View() string {
switch m.state {
case stateConfirmDelete:
kind := "monitor"
switch m.deleteTab {
case 1:
kind = "alert"
case 4:
switch m.deleteKind {
case "maint":
kind = "maintenance window"
case 5:
case "alert":
kind = "alert"
case "user":
kind = "user"
}
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
@@ -90,14 +90,14 @@ func (m Model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
}
return ""
case stateDetail:
return m.zones.Scan(m.viewDetailPanel())
case stateHistory:
return m.viewHistoryPanel()
case stateSLA:
return m.viewSLAPanel()
case stateLogs:
return m.viewLogsFullscreen()
case stateAlertDetail:
return m.viewAlertDetailPanel()
case stateSettings:
return m.viewSettingsOverlay()
case stateMaintDetail:
return m.viewMaintDetailPanel()
default:
return m.zones.Scan(m.viewDashboard())
}
@@ -130,7 +130,7 @@ func (m Model) computeStats() dashboardStats {
}
}
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > nodeStaleThreshold {
s.offlineNodes++
}
}
@@ -143,29 +143,67 @@ func (m Model) computeStats() dashboardStats {
return s
}
func (m Model) viewMonitorsLayout() string {
availW := m.termWidth - chromePadH
wide := m.termWidth >= wideBreakpoint
showDetail := m.detailOpen && wide
var detailW, monW int
if showDetail {
monW = availW * 60 / 100
detailW = availW - monW
} else {
monW = availW
}
m.contentWidth = monW - 2
monTargetH := m.maxTableRows + 5
monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanelH("Monitors", monitors, "", monW, monTargetH, 0, m.focusedPanel == panelMonitors))
var topParts []string
topParts = append(topParts, monPanel)
if showDetail {
title := ""
if m.cursor < len(m.sites) {
title = m.sites[m.cursor].Name
}
switch m.detailMode {
case detailSLA:
title = "SLA · " + title
case detailHistory:
title = "History · " + title
}
monHeight := lipgloss.Height(monPanel)
detail := m.viewDetailInline(detailW-2, monHeight)
footer := m.detailFooter(detailW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanelH(title, detail, footer, detailW, monHeight, m.detailScrollOffset, m.focusedPanel == panelDetail))
topParts = append(topParts, detailPanel)
}
top := lipgloss.JoinHorizontal(lipgloss.Top, topParts...)
switch m.bottomPanel {
case bottomLogs:
logContent := m.viewLogsStrip(availW-2, logsStripHeight-2)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", logContent, availW, m.focusedPanel == panelLogs))
return top + "\n" + logPanel
case bottomMaint:
maintContent := m.viewMaintStrip(availW-2, logsStripHeight-2)
maintPanel := m.zones.Mark("panel-maint", m.titledPanel("Maint", maintContent, availW, m.focusedPanel == panelMaint))
return top + "\n" + maintPanel
}
return top
}
func (m Model) viewDashboard() string {
stats := m.computeStats()
header := m.renderTabBar(stats)
header = m.pulseIndicator() + " " + header
header := m.renderStatusLine(stats)
var content string
switch m.currentTab {
case 0:
content = m.viewSitesTab()
case 1:
content = m.viewAlertsTab()
case 2:
content = m.viewLogsTab()
case 3:
content = m.viewNodesTab()
case 4:
content = m.viewMaintTab()
case 5:
if m.isAdmin {
content = m.viewUsersTab()
}
}
content := m.viewMonitorsLayout()
content = strings.TrimSpace(content)
footer := m.renderFooter(stats)
@@ -186,53 +224,8 @@ func (m Model) viewDashboard() string {
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
}
type tabEntry struct {
name string
count int
warn int
}
func (m Model) renderTabBar(stats dashboardStats) string {
tabs := []tabEntry{
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount},
{"Alerts", len(m.alerts), 0},
{"Logs", 0, 0},
{"Nodes", len(m.nodes), stats.offlineNodes},
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
}
if m.isAdmin {
tabs = append(tabs, tabEntry{"Users", len(m.users), 0})
}
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
var renderedTabs []string
for i, t := range tabs {
label := t.name
if t.count > 0 {
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
if t.warn > 0 {
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
}
label += badge
}
var rendered string
if i == m.currentTab {
rendered = m.st.activeTab.Render(label)
} else {
rendered = m.st.inactiveTab.Render(label)
}
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
}
return lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...)
}
func (m Model) renderFooter(stats dashboardStats) string {
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear")
}
func (m Model) renderStatusLine(stats dashboardStats) string {
dot := m.st.subtleStyle.Render(" · ")
upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string
@@ -243,45 +236,83 @@ func (m Model) renderFooter(stats dashboardStats) string {
} else {
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
}
statusParts := []string{upStr}
parts := []string{m.pulseIndicator() + " " + upStr}
if stats.lateCount > 0 {
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
parts = append(parts, m.st.warnStyle.Render(fmt.Sprintf("%d late", stats.lateCount)))
}
if stats.activeMaint > 0 {
parts = append(parts, m.st.maintStyle.Render(fmt.Sprintf("%d maint", stats.activeMaint)))
}
if len(m.nodes) > 0 {
online := 0
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
online++
}
}
probeLabel := "probes"
label := "probes"
if online == 1 {
probeLabel = "probe"
label = "probe"
}
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
}
statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · "))
var keys string
switch m.currentTab {
case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit"
case 1:
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
case 2:
keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit"
case 4:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5:
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
default:
keys = "[T]Theme [Tab]Switch [q]Quit"
parts = append(parts, fmt.Sprintf("%d %s", online, label))
}
left := strings.Join(parts, dot)
ver := m.st.subtleStyle.Render("v" + m.version)
footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == 0 {
footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
padW := m.termWidth - chromePadH - lipgloss.Width(left) - lipgloss.Width(ver)
if padW < 2 {
padW = 2
}
return footer
return left + strings.Repeat(" ", padW) + ver
}
func (m Model) hotkey(key, desc string) string {
k := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(key)
d := m.st.subtleStyle.Render(desc)
return k + " " + d
}
func (m Model) renderFooter(_ dashboardStats) string {
dot := m.st.subtleStyle.Render(" · ")
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
keys := m.hotkey("Enter", "Apply") + dot + m.hotkey("Esc", "Clear")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + keys
}
var parts []string
if m.focusedPanel == panelMaint {
parts = []string{m.hotkey("n", "New"), m.hotkey("Enter", "Detail"), m.hotkey("x", "End"), m.hotkey("d", "Del"), m.hotkey("m/Esc", "Back")}
} else if m.focusedPanel == panelLogs {
parts = []string{m.hotkey("↑/↓", "Scroll"), m.hotkey("Enter", "Expand"), m.hotkey("l/Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailSLA {
parts = []string{m.hotkey("1-4", "Period"), m.hotkey("Esc", "Back")}
} else if m.detailOpen && m.detailMode == detailHistory {
parts = []string{m.hotkey("Esc", "Back")}
} else if m.detailOpen {
parts = []string{m.hotkey("Enter", "Close"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("e", "Edit")}
} else {
parts = []string{m.hotkey("/", "Filter"), m.hotkey("Enter", "Detail")}
if m.cursor < len(m.sites) && m.sites[m.cursor].Type == "group" {
if m.collapsed[m.sites[m.cursor].ID] {
parts = append(parts, m.hotkey("Space", "Expand"))
} else {
parts = append(parts, m.hotkey("Space", "Collapse"))
}
}
parts = append(parts, m.hotkey("n", "New"), m.hotkey("e", "Edit"), m.hotkey("d", "Del"))
}
parts = append(parts, m.hotkey("m", "Maint"), m.hotkey("l", "Logs"), m.hotkey("S", "Settings"), m.hotkey("T", "Theme"), m.hotkey("q", "Quit"))
line := strings.Join(parts, dot)
if m.filterText != "" {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s ", m.filterText)) + line
}
return "\n" + line
}
-306
View File
@@ -1,306 +0,0 @@
package tui
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailPanel() string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
var breadcrumb string
if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = m.st.subtleStyle.Render(" Sites > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
}
section := func(label string) {
b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n")
}
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errWidth := m.termWidth - chromePadH - 19
if errWidth < 30 {
errWidth = 30
}
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError)
row("Error", m.st.dangerStyle.Render(wrapped))
}
if site.Type == "http" && site.StatusCode > 0 {
row("HTTP Code", strconv.Itoa(site.StatusCode))
}
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 {
b.WriteString("\n")
for _, step := range chain {
var icon string
switch step.Status {
case stepPassed:
icon = m.st.specialStyle.Render("✓")
case stepFailed:
icon = m.st.dangerStyle.Render("✗")
case stepSkipped:
icon = m.st.subtleStyle.Render("·")
}
line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" {
switch step.Status {
case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail)
case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail)
}
}
b.WriteString(line + "\n")
}
}
}
if !site.StatusChangedAt.IsZero() {
dur := time.Since(site.StatusChangedAt)
row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")
}
if !site.LastSuccessAt.IsZero() {
ago := time.Since(site.LastSuccessAt)
row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)")
}
if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
row("Maintenance", m.st.maintStyle.Render(mw.Title))
break
}
}
}
section("ENDPOINT")
row("Type", site.Type)
if site.Type == "push" && site.Token != "" {
row("Token", site.Token)
row("Push", "curl -X POST -H 'Authorization: Bearer "+site.Token+"' <host>/api/push")
}
if site.URL != "" {
row("URL", site.URL)
}
if site.Hostname != "" {
row("Host", site.Hostname)
}
if site.Port > 0 {
row("Port", strconv.Itoa(site.Port))
}
section("TIMING")
row("Interval", fmt.Sprintf("%ds", site.Interval))
if site.Timeout > 0 {
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
}
row("Latency", m.fmtLatency(site.Latency))
row("Uptime", m.fmtUptime(hist.Statuses))
if !site.LastCheck.IsZero() {
row("Last Check", m.fmtTimeAgo(site.LastCheck))
}
if site.Type == "http" {
section("HTTP")
if site.Method != "" && site.Method != "GET" {
row("Method", site.Method)
}
codes := site.AcceptedCodes
if codes == "" {
codes = "200-299"
}
row("Codes", codes)
row("SSL", m.fmtSSL(site))
if site.IgnoreTLS {
row("TLS Verify", m.st.dangerStyle.Render("disabled"))
}
}
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
section("CONFIG")
if site.MaxRetries > 0 {
row("Retries", m.fmtRetries(site))
}
if site.Regions != "" {
row("Regions", site.Regions)
}
if site.Description != "" {
row("Description", site.Description)
}
}
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults))
for id := range probeResults {
nodeIDs = append(nodeIDs, id)
}
sort.Strings(nodeIDs)
b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n")
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
status := m.st.specialStyle.Render("UP")
if !result.IsUp {
status = m.st.dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason)
}
b.WriteString(line + "\n")
}
}
// Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO.
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n")
for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == string(models.StatusUp) {
arrow += m.st.specialStyle.Render(sc.ToStatus)
} else {
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
line := fmt.Sprintf(" %s %s", arrow, m.st.subtleStyle.Render(ago+" ago"))
if dur := computeOutageDuration(stateChanges, i); dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
}
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
}
b.WriteString(line + "\n")
}
b.WriteString(" " + m.st.subtleStyle.Render("[h] History") + "\n")
}
b.WriteString(m.divider() + "\n")
if site.Type == "push" {
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, "")))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
fmt.Fprintf(&b, "\n %s %d/%d checks up",
m.st.subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses))
}
} else {
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, "")))
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
if count > 0 {
avg := total / time.Duration(count)
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
m.st.subtleStyle.Render("Max"), maxL.Milliseconds())
}
}
if m.sparkTooltipIdx >= 0 {
b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth))
}
b.WriteString("\n")
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string {
idx := m.sparkTooltipIdx
var dataLen int
if site.Type == "push" {
dataLen = len(hist.Statuses)
} else {
dataLen = len(hist.Latencies)
}
if idx < 0 || idx >= dataLen {
return ""
}
var parts []string
checksAgo := dataLen - 1 - idx
approxSecs := checksAgo * site.Interval
if approxSecs == 0 {
parts = append(parts, "latest")
} else {
parts = append(parts, "~"+fmtDuration(time.Duration(approxSecs)*time.Second)+" ago")
}
if site.Type != "push" && idx < len(hist.Latencies) {
parts = append(parts, m.fmtLatency(hist.Latencies[idx]))
}
if idx < len(hist.Statuses) {
if hist.Statuses[idx] {
parts = append(parts, m.st.specialStyle.Render("UP"))
} else {
parts = append(parts, m.st.dangerStyle.Render("DOWN"))
}
}
sep := m.st.subtleStyle.Render(" | ")
pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
return " " + strings.Join(parts, sep) + " " + pos
}
+381
View File
@@ -0,0 +1,381 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailInline(width, height int) string {
if m.cursor >= len(m.sites) {
return ""
}
switch m.detailMode {
case detailSLA:
return m.viewSLASidebar(width, height)
case detailHistory:
return m.viewHistorySidebar(width, height)
default:
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
return m.viewDetailSidebar(site, hist, width, height)
}
}
func (m Model) viewDetailSidebar(site models.Site, hist monitor.SiteHistory, width, _ int) string {
dot := m.st.subtleStyle.Render(" · ")
label := m.st.subtleStyle
var b strings.Builder
innerW := width - 4
if innerW < 20 {
innerW = 20
}
// Status + latency + last check
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
statusParts := []string{status}
if site.Latency > 0 {
statusParts = append(statusParts, m.fmtLatency(site.Latency))
}
if !site.LastCheck.IsZero() {
statusParts = append(statusParts, m.fmtTimeAgo(site.LastCheck))
}
b.WriteString(" " + strings.Join(statusParts, dot) + "\n")
// Type-specific details
typeParts := m.detailTypeLine(site)
if len(typeParts) > 0 {
b.WriteString(" " + strings.Join(typeParts, dot) + "\n")
}
// Uptime + retries
uptimeStr := m.fmtUptime(hist.Statuses)
if m.isMonitorInMaintenance(site.ID) {
uptimeStr = m.st.subtleStyle.Render("—")
}
uptimeParts := []string{label.Render("Uptime") + " " + uptimeStr}
if site.Type != "group" && site.MaxRetries > 0 {
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
}
b.WriteString(" " + strings.Join(uptimeParts, dot) + "\n")
// Error line
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := innerW
if errW < 20 {
errW = 20
}
b.WriteString(" " + label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
}
b.WriteString("\n")
// Latency chart
if len(hist.Latencies) > 0 {
chart := m.latencyChart(hist.Latencies, hist.Statuses, innerW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
}
// 30d uptime timeline
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
b.WriteString(" " + label.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, innerW) + "\n")
}
// Sparkline + min/avg/max
if site.Type != "push" && len(hist.Latencies) > 0 {
b.WriteString(" " + m.latencySparkline(hist.Latencies, hist.Statuses, innerW, nil) + "\n")
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
if i < len(hist.Statuses) && !hist.Statuses[i] {
continue
}
if count == 0 {
minL, maxL = l, l
} else if l < minL {
minL = l
} else if l > maxL {
maxL = l
}
total += l
count++
}
if count > 0 {
avg := total / time.Duration(count)
fmt.Fprintf(&b, " %s %dms %s %dms %s %dms\n",
label.Render("Min"), minL.Milliseconds(),
label.Render("Avg"), avg.Milliseconds(),
label.Render("Max"), maxL.Milliseconds())
}
}
b.WriteString("\n")
// State changes
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
limit := 5
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 + " " + label.Render(ago+" ago")
if sc.ErrorReason != "" {
reasonW := innerW - 25
if reasonW < 15 {
reasonW = 15
}
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
}
b.WriteString(" " + entry + "\n")
}
} else {
b.WriteString(" " + label.Render("No state changes") + "\n")
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) detailTypeLine(site models.Site) []string {
label := m.st.subtleStyle
var parts []string
switch site.Type {
case "http":
if site.StatusCode > 0 {
codeStr := fmt.Sprintf("HTTP %d", site.StatusCode)
if site.StatusCode >= httpErrorThreshold {
parts = append(parts, m.st.dangerStyle.Render(codeStr))
} else {
parts = append(parts, m.st.specialStyle.Render(codeStr))
}
}
if site.CheckSSL && site.HasSSL {
days := int(time.Until(site.CertExpiry).Hours() / 24)
sslStr := fmt.Sprintf("SSL %dd", days)
switch {
case days <= 0:
parts = append(parts, m.st.dangerStyle.Render("SSL EXPIRED"))
case days <= site.ExpiryThreshold:
parts = append(parts, m.st.warnStyle.Render(sslStr))
default:
parts = append(parts, m.st.specialStyle.Render(sslStr))
}
}
if site.URL != "" {
parts = append(parts, label.Render(limitStr(site.URL, 40)))
}
case "push":
parts = append(parts, label.Render("Push"))
if site.Interval > 0 {
parts = append(parts, label.Render(fmt.Sprintf("every %s", fmtDuration(time.Duration(site.Interval)*time.Second))))
}
if !site.LastSuccessAt.IsZero() {
parts = append(parts, label.Render("last")+" "+m.fmtTimeAgo(site.LastSuccessAt))
}
case "ping":
parts = append(parts, label.Render("Ping"))
if site.Hostname != "" {
parts = append(parts, label.Render(site.Hostname))
}
case "port":
parts = append(parts, label.Render("Port"))
if site.Hostname != "" {
target := site.Hostname
if site.Port > 0 {
target = fmt.Sprintf("%s:%d", site.Hostname, site.Port)
}
parts = append(parts, label.Render(target))
}
case "dns":
parts = append(parts, label.Render("DNS"))
if site.DNSResolveType != "" {
parts = append(parts, label.Render(site.DNSResolveType))
}
if site.DNSServer != "" {
parts = append(parts, label.Render(site.DNSServer))
}
}
return parts
}
func (m Model) detailFooter(width int) string {
dot := m.st.subtleStyle.Render(" · ")
var parts []string
switch m.detailMode {
case detailSLA:
for i, p := range slaPeriods {
if i == m.slaPeriodIdx {
parts = append(parts, m.st.titleStyle.Render(p.key)+" "+m.st.titleStyle.Render(p.label))
} else {
parts = append(parts, m.hotkey(p.key, p.label))
}
}
parts = append(parts, m.hotkey("Esc", "Back"))
case detailHistory:
parts = append(parts, m.hotkey("Esc", "Back"))
default:
parts = append(parts, m.hotkey("e", "Edit"), m.hotkey("h", "History"), m.hotkey("s", "SLA"), m.hotkey("Esc", "Back"))
}
content := " " + strings.Join(parts, dot)
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(content)
}
func (m Model) fmtStatusWord(status string) string {
switch status {
case "DOWN", "SSL EXP":
return m.st.dangerStyle.Render(status)
case "UP":
return m.st.specialStyle.Render("UP")
case "LATE":
return m.st.warnStyle.Render("LATE")
case "STALE":
return m.st.staleStyle.Render("STALE")
case "PENDING":
return m.st.subtleStyle.Render("PENDING")
case "PAUSED":
return m.st.warnStyle.Render("PAUSED")
default:
return m.st.subtleStyle.Render(status)
}
}
func (m Model) viewSLASidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + label.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := innerW - 25
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < uptimeExcellentPct {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < uptimeGoodPct {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-14s %s %s\n", label.Render("Uptime"), uptimeColor.Render(fmtPct(r.UptimePct)+"%"), bar)
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-14s %d\n", label.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-14s %s\n", label.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-14s %s\n", label.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(" " + m.st.titleStyle.Render("DAILY BREAKDOWN") + "\n")
dayBarW := innerW - 20
if dayBarW < 10 {
dayBarW = 10
}
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
dayBar := m.uptimeBar(day.UptimePct, dayBarW)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < uptimeExcellentPct {
color = m.st.warnStyle
}
if day.UptimePct < uptimeGoodPct {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", label.Render(dateStr), dayBar, color.Render(pctStr))
}
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) viewHistorySidebar(width, _ int) string {
var b strings.Builder
label := m.st.subtleStyle
innerW := width - 4
if innerW < 20 {
innerW = 20
}
sparkline := m.stateChangeSparkline(m.historyChanges, innerW)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n\n")
}
if len(m.historyChanges) == 0 {
b.WriteString(" " + label.Render("No state changes recorded") + "\n")
} else {
reasonW := innerW - 45
if reasonW < 10 {
reasonW = 10
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("01/02 15:04")
arrow := label.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render(fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
}
fmt.Fprintf(&b, " %-12s %s %s %s\n", ts, arrow, durStr, reason)
}
}
b.WriteString("\n")
stats := computeHistoryStats(m.historyChanges)
statParts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
statParts = append(statParts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
statParts = append(statParts, "avg "+fmtDuration(avg))
}
b.WriteString(" " + label.Render(strings.Join(statParts, " │ ")) + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
-87
View File
@@ -1,12 +1,10 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
type historyStats struct {
@@ -105,88 +103,3 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
}
return sb.String()
}
func (m Model) buildHistoryContent() string {
var b strings.Builder
reasonWidth := m.termWidth - chromePadH - 55
if reasonWidth < 10 {
reasonWidth = 10
}
if reasonWidth > 60 {
reasonWidth = 60
}
for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus {
case string(models.StatusUp):
arrow += m.st.specialStyle.Render(sc.ToStatus)
case string(models.StatusLate):
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
}
durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur))
}
reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
}
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
}
return b.String()
}
func (m Model) viewHistoryPanel() string {
var b strings.Builder
header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName)
header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n")
divWidth := m.dividerWidth()
b.WriteString(m.divider() + "\n")
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" {
b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n")
}
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
m.st.subtleStyle.Render("TIME"),
m.st.subtleStyle.Render("TRANSITION"),
m.st.subtleStyle.Render("DURATION"),
m.st.subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 {
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n")
} else {
b.WriteString(m.historyViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
stats := computeHistoryStats(m.historyChanges)
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
if stats.outageCount > 0 {
parts = append(parts, fmt.Sprintf("%d outages", stats.outageCount))
avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg))
}
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
+199
View File
@@ -0,0 +1,199 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
func (m Model) viewMaintDetailPanel() string {
mw := m.findMaintWindow(m.maintDetailID)
if mw == nil {
return lipgloss.NewStyle().Padding(1, 2).Render(m.st.subtleStyle.Render("Maintenance window not found"))
}
var b strings.Builder
header := " " + m.st.subtleStyle.Render("Maintenance >") + " " + m.st.titleStyle.Render(mw.Title)
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
}
maintType := m.st.maintStyle.Render("maintenance")
if mw.Type == "incident" {
maintType = m.st.dangerStyle.Render("incident")
}
row("Type", maintType)
now := time.Now()
if mw.StartTime.After(now) {
row("Status", m.st.warnStyle.Render("SCHEDULED"))
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
row("Status", m.st.subtleStyle.Render("ENDED"))
} else {
row("Status", m.st.specialStyle.Render("ACTIVE"))
}
if mw.MonitorID == 0 {
row("Monitors", "All")
} else {
name := fmt.Sprintf("#%d", mw.MonitorID)
allSites := m.engine.GetAllSites()
for _, s := range allSites {
if s.ID == mw.MonitorID {
name = s.Name
break
}
}
row("Monitors", name)
}
row("Started", mw.StartTime.Format("2006-01-02 15:04"))
if mw.EndTime.IsZero() {
row("Ends", m.st.subtleStyle.Render("indefinite"))
} else {
row("Ends", mw.EndTime.Format("2006-01-02 15:04"))
if mw.EndTime.After(now) && !mw.StartTime.After(now) {
remaining := time.Until(mw.EndTime)
row("Remaining", fmtDuration(remaining))
}
dur := mw.EndTime.Sub(mw.StartTime)
row("Duration", fmtDuration(dur))
}
if mw.Description != "" {
b.WriteString("\n")
row("Description", mw.Description)
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive {
keys = append(keys, "[x] End")
}
keys = append(keys, "[d] Delete", "[q/Esc] Back")
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(keys, " ")))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) monitorNameByID(id int) string {
for _, s := range m.engine.GetAllSites() {
if s.ID == id {
return s.Name
}
}
return fmt.Sprintf("#%d", id)
}
func (m Model) activeMaintWindows() []models.MaintenanceWindow {
now := time.Now()
var out []models.MaintenanceWindow
for _, mw := range m.maintenanceWindows {
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
continue
}
out = append(out, mw)
}
return out
}
func (m Model) viewMaintStrip(width, maxLines int) string {
windows := m.activeMaintWindows()
if len(windows) == 0 {
return m.st.subtleStyle.Render(" No active maintenance")
}
now := time.Now()
end := maxLines
if end > len(windows) {
end = len(windows)
}
selectedVisual := -1
if m.focusedPanel == panelMaint {
selectedVisual = m.maintCursor
}
var rows [][]string
for i := 0; i < end; i++ {
mw := windows[i]
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
var icon string
if isActive {
icon = m.st.specialStyle.Render("●")
} else {
icon = m.st.warnStyle.Render("○")
}
monName := "All Monitors"
if mw.MonitorID > 0 {
monName = m.monitorNameByID(mw.MonitorID)
}
var status string
if isActive {
if mw.EndTime.IsZero() {
status = "indefinite"
} else {
status = fmtDuration(time.Until(mw.EndTime)) + " left"
}
} else {
status = "starts " + mw.StartTime.Format("Jan 02 15:04")
}
rows = append(rows, []string{icon, mw.Title, monName, status})
}
colWidths := []int{3, 0, 0, 0}
remaining := width - colWidths[0] - 6
colWidths[1] = remaining * 40 / 100
colWidths[2] = remaining * 30 / 100
colWidths[3] = remaining - colWidths[1] - colWidths[2]
t := table.New().
Border(lipgloss.HiddenBorder()).
Width(width).
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
isSelected := row == selectedVisual
base := m.st.tableCellStyle
if row%2 == 1 {
base = m.st.tableZebraStyle
}
if isSelected {
base = m.st.tableSelectedStyle
}
if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
}
return base
})
return t.Render()
}
func (m *Model) scrollMaintCursor(delta int) {
windows := m.activeMaintWindows()
total := len(windows)
if total == 0 {
return
}
m.maintCursor += delta
if m.maintCursor < 0 {
m.maintCursor = 0
}
if m.maintCursor >= total {
m.maintCursor = total - 1
}
}
+87
View File
@@ -0,0 +1,87 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewSettingsOverlay() string {
overlayW := m.termWidth - 8
if overlayW > 90 {
overlayW = 90
}
if overlayW < 40 {
overlayW = 40
}
overlayH := m.termHeight - 6
if overlayH < 10 {
overlayH = 10
}
savedCursor, savedOffset := m.cursor, m.tableOffset
savedContentW := m.contentWidth
m.cursor = m.settingsCursor
m.tableOffset = m.settingsOffset
m.contentWidth = overlayW - 4
content := m.viewSettingsTab()
m.cursor = savedCursor
m.tableOffset = savedOffset
m.contentWidth = savedContentW
settingsListLen := m.settingsListLen()
footer := m.settingsFooterKeys()
if settingsListLen > 0 {
footer = fmt.Sprintf("%d items %s", settingsListLen, footer)
}
footerLine := m.st.subtleStyle.Render(footer)
inner := content + "\n" + footerLine
box := m.titledPanel("Settings", inner, overlayW, true)
boxLines := lipgloss.Height(box)
if boxLines > overlayH {
lines := splitLines(box, overlayH)
box = lines
}
return placeOverlay(box, m.termWidth, m.termHeight)
}
func (m Model) settingsFooterKeys() string {
switch m.settingsSection {
case sectionAlerts:
return "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [Esc]Close"
case sectionUsers:
if m.isAdmin {
return "[n]Add [d]Revoke [←/→]Section [Esc]Close"
}
return "[←/→]Section [Esc]Close"
default:
return "[←/→]Section [Esc]Close"
}
}
func (m Model) settingsListLen() int {
switch m.settingsSection {
case sectionAlerts:
return len(m.alerts)
case sectionNodes:
return len(m.nodes)
case sectionUsers:
return len(m.users)
}
return 0
}
func splitLines(s string, maxLines int) string {
lines := strings.Split(s, "\n")
if len(lines) > maxLines {
lines = lines[:maxLines]
}
return strings.Join(lines, "\n")
}
+1 -89
View File
@@ -5,8 +5,6 @@ import (
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
var slaPeriods = []struct {
@@ -21,92 +19,6 @@ var slaPeriods = []struct {
{"90d", "4", 90 * 24 * time.Hour, 90},
}
func (m Model) viewSLAPanel() string {
var b strings.Builder
header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName)
header += " " + m.st.subtleStyle.Render("[q] Back")
b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
bar := m.uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle
if r.UptimePct < 99.9 {
uptimeColor = m.st.warnStyle
}
if r.UptimePct < 99.0 {
uptimeColor = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
}
b.WriteString("\n" + m.divider() + "\n")
if len(m.slaDailyBreakdown) > 0 {
b.WriteString(m.slaViewport.View())
}
b.WriteString("\n" + m.divider() + "\n")
var keys []string
for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(label))
} else {
keys = append(keys, m.st.subtleStyle.Render(label))
}
}
b.WriteString(" " + strings.Join(keys, " "))
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m Model) buildSLADailyContent() string {
var b strings.Builder
barWidth := m.dividerWidth() - 30
if barWidth < 10 {
barWidth = 10
}
b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02")
bar := m.uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle
if day.UptimePct < 99.9 {
color = m.st.warnStyle
}
if day.UptimePct < 99.0 {
color = m.st.dangerStyle
}
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr))
}
return b.String()
}
func (m Model) uptimeBar(pct float64, width int) string {
filled := int(math.Round(pct / 100 * float64(width)))
if filled > width {
@@ -128,7 +40,7 @@ func fmtPct(pct float64) string {
if pct == 100 {
return "100.00"
}
if pct >= 99.99 {
if pct >= uptimePrecisionPct {
return fmt.Sprintf("%.3f", pct)
}
return fmt.Sprintf("%.2f", pct)