34 Commits

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

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

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

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

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

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

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

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

No more full-screen detail takeover for the common case. The old
stateDetail path remains for h/s sub-views which still go full-screen.
2026-06-20 18:58:49 -04:00
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
56 changed files with 1241 additions and 449 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
+23 -10
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/monitors.png" alt="monitors dashboard" width="400"></td>
<td><img src="assets/detail.png" alt="detail panel" width="400"></td>
</tr>
<tr>
<td><img src="assets/alerts.png" alt="alerts view" width="400"></td>
</tr>
<tr>
<td><img src="assets/logs.png" alt="logs view" 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>
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
<td><img src="assets/theme.png" alt="theme selection" width="400"></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

After

Width:  |  Height:  |  Size: 83 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 976 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 84 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

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 212 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 212 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

+33 -6
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
@@ -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
@@ -219,7 +246,7 @@ 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
@@ -239,7 +266,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)
@@ -331,7 +358,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")
+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.
+5
View File
@@ -86,6 +86,11 @@ type ProbeNode struct {
Version string
}
type LogEntry struct {
Message string
CreatedAt time.Time
}
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
// "last sent" / health indicators survive restarts instead of resetting to "never".
type AlertHealthRecord struct {
+13 -11
View File
@@ -40,7 +40,7 @@ type Engine struct {
liveState map[int]models.Site
logMu sync.RWMutex
logStore []string
logStore []models.LogEntry
activeMu sync.RWMutex
isActive bool
@@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string {
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
// it. It never touches the database, so it is safe to call from the db-write
// drop/error path without recursing back through the write queue.
func (e *Engine) appendLog(msg string) string {
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
func (e *Engine) appendLog(msg string) models.LogEntry {
entry := models.LogEntry{
Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock()
e.logStore = append([]string{entry}, e.logStore...)
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries]
}
@@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string {
func (e *Engine) AddLog(msg string) {
entry := e.appendLog(msg)
e.enqueueWrite(writeLog{message: entry})
e.enqueueWrite(writeLog{message: entry.Message})
}
// enqueueWrite hands a persistence task to the writer goroutine without
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
}
func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil {
return
}
if len(logs) == 0 {
if len(entries) == 0 {
return
}
e.logMu.Lock()
defer e.logMu.Unlock()
e.logStore = logs
e.logStore = entries
}
// InitAlertHealth restores persisted alert send health so the dashboard shows real
@@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() {
}
}
func (e *Engine) GetLogs() []string {
func (e *Engine) GetLogs() []models.LogEntry {
e.logMu.RLock()
defer e.logMu.RUnlock()
logs := make([]string, len(e.logStore))
logs := make([]models.LogEntry, len(e.logStore))
copy(logs, e.logStore)
return logs
}
+9 -6
View File
@@ -25,7 +25,7 @@ type mockStore struct {
sites []models.SiteConfig
alerts map[int]models.AlertConfig
maintenance map[int]bool
logs []string
logs []models.LogEntry
history map[int][]models.CheckRecord
savedChecks []savedCheck
savedLogs []string
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
return nil
}
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) {
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) {
return m.logs, nil
}
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l, "suppressed") {
if containsStr(l.Message, "suppressed") {
found = true
break
}
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
if len(logs) != 100 {
t.Errorf("expected 100 logs, got %d", len(logs))
}
if !containsStr(logs[0], "log-104") {
t.Errorf("expected newest log first, got %s", logs[0])
if !containsStr(logs[0].Message, "log-104") {
t.Errorf("expected newest log first, got %s", logs[0].Message)
}
}
func TestInitLogs_LoadsFromDB(t *testing.T) {
ms := newMockStore()
ms.logs = []string{"old-log-1", "old-log-2"}
ms.logs = []models.LogEntry{
{Message: "old-log-1"},
{Message: "old-log-2"},
}
e := newTestEngine(ms)
e.InitLogs()
-4
View File
@@ -159,10 +159,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"))
}
+2 -2
View File
@@ -219,8 +219,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)
}
}
+15 -8
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
return err
}
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []string
var entries []models.LogEntry
for rows.Next() {
var msg string
if err := rows.Scan(&msg); err != nil {
return logs, err
var e models.LogEntry
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
return entries, err
}
logs = append(logs, msg)
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
if len(e.Message) > 3 && e.Message[0] == '[' {
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
e.Message = strings.TrimSpace(e.Message[idx+1:])
}
return logs, rows.Err()
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
+1 -1
View File
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
// LoadLogs ordering ties when rows share a created_at second).
present := make(map[string]bool, len(logs))
for _, l := range logs {
present[l] = true
present[l.Message] = true
}
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
t.Error("newest log was pruned")
+1 -1
View File
@@ -61,7 +61,7 @@ type Store interface {
// Logs
SaveLog(ctx context.Context, message string) error
LoadLogs(ctx context.Context, limit int) ([]string, error)
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
PruneLogs(ctx context.Context) error
// Maintenance Windows
+1 -1
View File
@@ -212,7 +212,7 @@ 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) 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) {
+2 -2
View File
@@ -106,7 +106,7 @@ func (m *Model) refreshLive() {
m.sites = ordered
m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 {
if m.currentTab == tabMonitors && m.selectedID != 0 {
for i, s := range m.sites {
if s.ID == m.selectedID {
m.cursor = i
@@ -118,7 +118,7 @@ func (m *Model) refreshLive() {
}
func (m *Model) syncSelectedID() {
if m.currentTab == 0 && m.cursor < len(m.sites) {
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
m.selectedID = m.sites[m.cursor].ID
}
}
+52
View File
@@ -0,0 +1,52 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m Model) titledPanel(title, content string, width int, focused bool) string {
borderColor := m.theme.Border
titleColor := m.theme.Muted
if focused {
borderColor = m.theme.Accent
titleColor = m.theme.Accent
}
bc := lipgloss.NewStyle().Foreground(borderColor)
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
innerW := width - 2
if innerW < 10 {
innerW = 10
}
titleRendered := tc.Render(" " + title + " ")
titleLen := len([]rune(title)) + 2
fillLen := innerW - titleLen - 1
if fillLen < 0 {
fillLen = 0
}
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
inner := contentStyle.Render(content)
var lines []string
lines = append(lines, top)
for _, line := range strings.Split(inner, "\n") {
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
}
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
return strings.Join(lines, "\n")
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+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)
}
+20 -1
View File
@@ -185,7 +185,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,6 +206,25 @@ 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)
return tbl + "\n " + m.st.subtleStyle.Render(summary)
}
func (m Model) viewAlertDetailPanel() string {
+12 -45
View File
@@ -3,6 +3,8 @@ package tui
import (
"fmt"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type logSeverity int
@@ -15,8 +17,8 @@ const (
severitySystem
)
func classifyLog(line string) logSeverity {
lower := strings.ToLower(line)
func classifyLog(msg string) logSeverity {
lower := strings.ToLower(msg)
switch {
case strings.Contains(lower, "confirmed down"),
strings.Contains(lower, "is down"),
@@ -63,67 +65,32 @@ 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:])
}
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
}
if ts != "" {
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
}
return fmt.Sprintf(" %s %s", tag, msg)
}
// refreshLogContent rebuilds the log viewport from the full engine log list,
// filtering before windowing so the entry count and "(n hidden)" reflect all
// logs, not just the visible viewport slice.
func (m *Model) refreshLogContent() {
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) viewLogsSidebar(width, maxLines int) string {
logs := m.engine.GetLogs()
if len(logs) == 0 {
return m.st.subtleStyle.Render(" No logs yet")
}
sidebarStyle := 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 sidebarStyle.Render(strings.Join(visible, "\n"))
}
func (m *Model) scrollLogs(delta int) {
logs := m.engine.GetLogs()
total := 0
for _, entry := range logs {
if strings.TrimSpace(entry.Message) == "" {
continue
}
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
continue
}
total++
}
m.logScrollOffset += delta
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
if m.logScrollOffset > total-1 {
m.logScrollOffset = total - 1
}
if m.logScrollOffset < 0 {
m.logScrollOffset = 0
}
}
+16 -1
View File
@@ -107,7 +107,7 @@ func (m Model) viewMaintTab() string {
monW := widths[3]
timeW := widths[5]
return m.renderTable(
tbl := m.renderTable(
headers,
len(m.maintenanceWindows),
func(start, end int) [][]string {
@@ -130,6 +130,21 @@ func (m Model) viewMaintTab() string {
widths,
nil,
)
now := time.Now()
var active, scheduled, ended int
for _, mw := range m.maintenanceWindows {
if mw.StartTime.After(now) {
scheduled++
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
ended++
} else {
active++
}
}
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
return tbl + "\n " + m.st.subtleStyle.Render(summary)
}
func (m *Model) initMaintHuhForm() tea.Cmd {
+27 -1
View File
@@ -1,6 +1,8 @@
package tui
import (
"fmt"
"strings"
"time"
)
@@ -20,7 +22,7 @@ func (m Model) viewNodesTab() string {
}
nameW := widths[0]
return m.renderTable(
tbl := m.renderTable(
headers,
len(m.nodes),
func(start, end int) [][]string {
@@ -48,6 +50,30 @@ func (m Model) viewNodesTab() string {
widths,
nil,
)
var online int
regions := make(map[string]bool)
var leader string
for _, n := range m.nodes {
if time.Since(n.LastSeen) < 60*time.Second {
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 {
+58
View File
@@ -0,0 +1,58 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewSettingsTab() string {
maxSections := 2
if m.isAdmin {
maxSections = 3
}
sections := []string{"Alerts", "Nodes"}
if m.isAdmin {
sections = append(sections, "Users")
}
_ = maxSections
var tabs []string
for i, name := range sections {
if i == m.settingsSection {
tabs = append(tabs, m.st.activeTab.Render(name))
} else {
tabs = append(tabs, m.st.inactiveTab.Render(name))
}
}
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
}
+7 -3
View File
@@ -82,8 +82,12 @@ func (m Model) computeLayout() tableLayout {
var widths []int
var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
for _, c := range siteColumns {
if c.minTerm > 0 && m.termWidth < c.minTerm {
if c.minTerm > 0 && cw < c.minTerm {
continue
}
active = append(active, c.key)
@@ -104,7 +108,7 @@ func (m Model) computeLayout() tableLayout {
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 +208,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 {
+1 -1
View File
@@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd {
st := m.store
id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role
m.state = stateUsers
m.state = stateDashboard
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(context.Background(), id, 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 {
+38 -13
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),
@@ -84,6 +84,24 @@ const (
detailSparkWidth = 40
)
const (
tabMonitors = 0
tabMaint = 1
tabSettings = 2
)
const (
sectionAlerts = 0
sectionNodes = 1
sectionUsers = 2
)
const (
panelMonitors = 0
panelLogs = 1
panelDetail = 2
)
type sessionState int
const (
@@ -104,12 +122,16 @@ const (
type Model struct {
state sessionState
currentTab int
settingsSection int
cursor int
selectedID int
tableOffset int
maxTableRows int
termWidth int
termHeight int
contentWidth int
focusedPanel int
logScrollOffset int
editID int
editToken string
@@ -165,7 +187,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
// detail-panel state-change history, loaded on enter so View does no DB IO
detailOpen bool
detailChanges []models.StateChange
detailChangesSiteID int
@@ -197,6 +219,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
}
}
detailPref, _ := s.GetPreference(context.Background(), "detail_open")
return Model{
state: stateDashboard,
logViewport: vpLogs,
@@ -210,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
theme: theme,
themeIndex: themeIdx,
st: newStyles(theme),
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
+188 -100
View File
@@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
id := m.deleteID
var cmd tea.Cmd
switch m.deleteTab {
case 0:
case tabMonitors:
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1)
case 1:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
m.adjustCursor(len(m.alerts) - 1)
case 4:
case tabMaint:
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5:
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
m.adjustCursor(len(m.alerts) - 1)
case sectionUsers:
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
m.adjustCursor(len(m.users) - 1)
}
}
m.refreshLive()
m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
return m, cmd
case "n", "N", "esc":
m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
case "ctrl+c":
return m, tea.Quit
}
@@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg.String() == "esc" {
m.huhForm = nil
m.state = stateDashboard
if m.currentTab == 5 {
m.state = stateUsers
}
return m, nil
}
}
@@ -148,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
const detailInlineHeight = 8
func (m *Model) recalcLayout() {
chrome := chromeBase
if m.filterMode || m.filterText != "" {
chrome++
}
if m.detailOpen && m.currentTab == tabMonitors {
chrome += detailInlineHeight
}
m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 {
m.maxTableRows = 1
@@ -265,7 +264,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
return m, nil
}
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
if m.state != stateDashboard {
return m, nil
}
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
@@ -275,11 +274,11 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if m.state == stateLogs {
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp {
m.logViewport.ScrollUp(3)
m.scrollLogs(-3)
} else {
m.logViewport.ScrollDown(3)
m.scrollLogs(3)
}
return m, nil
}
@@ -301,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil
}
@@ -325,7 +327,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleSLAKey(msg)
case stateAlertDetail:
return m.handleAlertDetailKey(msg)
case stateDashboard, stateLogs, stateUsers:
case stateDashboard:
return m.handleDashboardKey(msg)
}
return m, nil
@@ -521,43 +523,56 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg.String() {
case "q":
return m, tea.Quit
case "/":
if m.currentTab == 0 {
if m.currentTab == tabMonitors {
m.filterMode = true
m.recalcLayout()
return m, nil
}
case "f":
if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
return m, nil
}
case "tab":
m.switchTab(m.currentTab + 1)
case "pgup", "pgdown":
if m.state == stateLogs {
m.logViewport, cmd = m.logViewport.Update(msg)
return m, cmd
case "left":
if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection - 1)
}
case "right":
if m.currentTab == tabSettings {
m.switchSettingsSection(m.settingsSection + 1)
}
case "l":
switch m.currentTab {
case tabSettings:
m.switchSettingsSection(m.settingsSection + 1)
case tabMonitors:
if m.focusedPanel == panelLogs {
m.focusedPanel = panelMonitors
} else {
m.focusedPanel = panelLogs
}
}
case "up", "k":
if m.state == stateLogs {
m.logViewport.ScrollUp(1)
} else if m.cursor > 0 {
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(-1)
return m, nil
}
if m.cursor > 0 {
m.cursor--
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "down", "j":
if m.state == stateLogs {
m.logViewport.ScrollDown(1)
} else {
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.scrollLogs(1)
return m, nil
}
max := m.currentListLen() - 1
if m.cursor < max {
m.cursor++
@@ -565,19 +580,27 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.tableOffset++
}
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
}
case "n":
return m.handleNewItem()
case "e", "enter":
case "enter":
if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m.handleEditItem()
case "e":
return m.handleEditItem()
case "t":
if m.currentTab == 1 && len(m.alerts) > 0 {
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
a := m.alerts[m.cursor]
return m, m.testAlertCmd(a.ID, a.Name)
}
case " ":
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
if m.currentTab == tabMonitors && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid]
payload := collapsedJSON(m.collapsed)
@@ -588,7 +611,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
})
}
case "p":
if m.currentTab == 0 && len(m.sites) > 0 {
if m.currentTab == tabMonitors && len(m.sites) > 0 {
id := m.sites[m.cursor].ID
paused := m.engine.ToggleSitePause(id)
st := m.store
@@ -598,14 +621,62 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
})
}
case "i":
if m.currentTab == 0 && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} else if m.currentTab == 1 && len(m.alerts) > 0 {
if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.detailOpen = !m.detailOpen
m.recalcLayout()
st := m.store
open := m.detailOpen
var cmd tea.Cmd
if m.detailOpen {
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
}
saveCmd := writeCmd("Save detail preference", func() error {
v := "false"
if open {
v = "true"
}
return st.SetPreference(context.Background(), "detail_open", v)
})
if cmd != nil {
return m, tea.Batch(cmd, saveCmd)
}
return m, saveCmd
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
m.state = stateAlertDetail
}
case "esc":
if m.currentTab == tabMonitors {
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen {
m.detailOpen = false
m.recalcLayout()
st := m.store
return m, writeCmd("Save detail preference", func() error {
return st.SetPreference(context.Background(), "detail_open", "false")
})
}
}
case "h":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "x":
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
mw := m.maintenanceWindows[m.cursor]
now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
@@ -637,88 +708,94 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0
m.editToken = ""
switch m.currentTab {
case 0:
case tabMonitors:
m.state = stateFormSite
return m, m.initSiteHuhForm()
case 1:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case 4:
case tabMaint:
m.state = stateFormMaint
return m, m.initMaintHuhForm()
case 5:
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case sectionUsers:
if m.isAdmin {
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
}
return m, nil
}
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
switch m.currentTab {
case 0:
case tabMonitors:
if len(m.sites) > 0 {
m.editID = m.sites[m.cursor].ID
m.editToken = m.sites[m.cursor].Token
m.state = stateFormSite
return m, m.initSiteHuhForm()
}
case 1:
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
if len(m.alerts) > 0 {
m.editID = m.alerts[m.cursor].ID
m.state = stateFormAlert
return m, m.initAlertHuhForm()
}
case 5:
case sectionUsers:
if m.isAdmin && len(m.users) > 0 {
m.editID = m.users[m.cursor].ID
m.state = stateFormUser
return m, m.initUserHuhForm()
}
}
}
return m, nil
}
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
switch m.currentTab {
case 0:
case tabMonitors:
if len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name
m.deleteTab = 0
m.deleteTab = tabMonitors
m.state = stateConfirmDelete
}
case 1:
if len(m.alerts) > 0 {
m.deleteID = m.alerts[m.cursor].ID
m.deleteName = m.alerts[m.cursor].Name
m.deleteTab = 1
m.state = stateConfirmDelete
}
case 4:
case tabMaint:
if len(m.maintenanceWindows) > 0 {
m.deleteID = m.maintenanceWindows[m.cursor].ID
m.deleteName = m.maintenanceWindows[m.cursor].Title
m.deleteTab = 4
m.deleteTab = tabMaint
m.state = stateConfirmDelete
}
case 5:
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
if len(m.alerts) > 0 {
m.deleteID = m.alerts[m.cursor].ID
m.deleteName = m.alerts[m.cursor].Name
m.deleteTab = tabSettings
m.state = stateConfirmDelete
}
case sectionUsers:
if m.isAdmin && len(m.users) > 0 {
m.deleteID = m.users[m.cursor].ID
m.deleteName = m.users[m.cursor].Username
m.deleteTab = 5
m.deleteTab = tabSettings
m.state = stateConfirmDelete
}
}
}
return m, nil
}
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
tabCount := 5
if m.isAdmin {
tabCount = 6
}
tabCount := tabSettings + 1
for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
m.switchTab(i)
@@ -726,6 +803,18 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
if m.currentTab == tabMonitors {
if m.zones.Get("panel-monitors").InBounds(msg) {
m.focusedPanel = panelMonitors
} else if m.zones.Get("panel-logs").InBounds(msg) {
m.focusedPanel = panelLogs
return m, nil
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
m.focusedPanel = panelDetail
return m, nil
}
}
prefix, listLen := m.currentZonePrefix()
end := m.tableOffset + m.maxTableRows
if end > listLen {
@@ -735,6 +824,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
m.cursor = i
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil
}
}
@@ -743,25 +835,15 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
func (m *Model) switchTab(idx int) {
maxTabs := 4
if m.isAdmin {
maxTabs = 5
}
maxTabs := tabSettings
if idx > maxTabs {
idx = 0
}
m.currentTab = idx
m.cursor = 0
m.tableOffset = 0
switch idx {
case 2:
m.state = stateLogs
case 5:
m.state = stateUsers
default:
m.state = stateDashboard
}
}
func (m *Model) adjustCursor(_ int) {
m.clampCursor()
@@ -791,30 +873,36 @@ func (m *Model) submitForm() tea.Cmd {
func (m Model) currentListLen() int {
switch m.currentTab {
case 1:
return len(m.alerts)
case 3:
return len(m.nodes)
case 4:
return len(m.maintenanceWindows)
case 5:
return len(m.users)
default:
case tabMonitors:
return len(m.sites)
case tabMaint:
return len(m.maintenanceWindows)
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
return len(m.alerts)
case sectionNodes:
return len(m.nodes)
case sectionUsers:
return len(m.users)
}
}
return 0
}
func (m Model) currentZonePrefix() (string, int) {
switch m.currentTab {
case 0:
case tabMonitors:
return "site", len(m.sites)
case 1:
return "alert", len(m.alerts)
case 4:
case tabMaint:
return "maint", len(m.maintenanceWindows)
case 5:
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
return "alert", len(m.alerts)
case sectionUsers:
return "user", len(m.users)
default:
}
}
return "site", 0
}
}
+1 -1
View File
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
mm := updated.(Model)
found := false
for _, line := range mm.engine.GetLogs() {
if strings.Contains(line, "Delete site failed: boom") {
if strings.Contains(line.Message, "Delete site failed: boom") {
found = true
}
}
+88 -36
View File
@@ -151,20 +151,52 @@ func (m Model) viewDashboard() string {
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()
case tabMonitors:
showSidebar := m.termWidth >= wideBreakpoint
if showSidebar {
availW := m.termWidth - chromePadH
leftW := availW * 70 / 100
rightW := availW - leftW
m.contentWidth = leftW - 2
monitors := m.viewSitesTab()
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = top + "\n" + detailPanel
} else {
content = top
}
} else {
m.contentWidth = m.termWidth - 2
monitors := m.viewSitesTab()
availW := m.termWidth - chromePadH
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
if m.detailOpen {
site := ""
if m.cursor < len(m.sites) {
site = m.sites[m.cursor].Name
}
detail := m.viewDetailInline(availW - 2)
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
content = monPanel + "\n" + detailPanel
} else {
content = monPanel
}
}
case tabMaint:
m.contentWidth = m.termWidth
content = m.viewMaintTab()
case tabSettings:
m.contentWidth = m.termWidth
content = m.viewSettingsTab()
}
content = strings.TrimSpace(content)
@@ -177,13 +209,19 @@ func (m Model) viewDashboard() string {
availHeight = 5
}
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
divW := m.termWidth - chromePadH
if divW < 40 {
divW = 40
}
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
if contentHeight < 1 {
contentHeight = 1
}
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
}
type tabEntry struct {
@@ -193,15 +231,15 @@ type tabEntry struct {
}
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},
}
settingsCount := len(m.alerts) + len(m.nodes)
settingsWarn := stats.offlineNodes
if m.isAdmin {
tabs = append(tabs, tabEntry{"Users", len(m.users), 0})
settingsCount += len(m.users)
}
tabs := []tabEntry{
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
{"Settings", settingsCount, settingsWarn},
}
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
@@ -264,24 +302,38 @@ func (m Model) renderFooter(stats dashboardStats) string {
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:
case tabMonitors:
if m.focusedPanel == panelLogs {
keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
} else if m.detailOpen {
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
} else {
keys = "[/]Filter [i]Info [Enter]Detail [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
}
case tabMaint:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5:
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
case tabSettings:
switch m.settingsSection {
case sectionAlerts:
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
case sectionUsers:
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
default:
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
}
default:
keys = "[T]Theme [Tab]Switch [q]Quit"
}
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
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == tabMonitors {
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
}
return footer
divW := m.termWidth - chromePadH
if divW < 40 {
divW = 40
}
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
}
+4 -4
View File
@@ -25,13 +25,13 @@ func (m Model) viewDetailPanel() 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)
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name)
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n")
b.WriteString(m.divider() + "\n")
@@ -215,7 +215,7 @@ func (m Model) viewDetailPanel() string {
b.WriteString(m.divider() + "\n")
if site.Type == "push" {
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, "")))
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil)))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
@@ -228,7 +228,7 @@ func (m Model) viewDetailPanel() string {
up, len(hist.Statuses))
}
} else {
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, "")))
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, nil)))
var minL, maxL, total time.Duration
count := 0
for i, l := range hist.Latencies {
+114
View File
@@ -0,0 +1,114 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) viewDetailInline(width int) string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
latency := m.fmtLatency(site.Latency)
uptime := m.fmtUptime(hist.Statuses)
line1Parts := []string{status}
if site.Latency > 0 {
line1Parts = append(line1Parts, latency)
}
line1Parts = append(line1Parts, fmt.Sprintf("Uptime %s", uptime))
if !site.LastCheck.IsZero() {
line1Parts = append(line1Parts, fmt.Sprintf("Checked %s", m.fmtTimeAgo(site.LastCheck)))
}
b.WriteString(" " + strings.Join(line1Parts, m.st.subtleStyle.Render(" · ")) + "\n")
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
errW := width - 12
if errW < 20 {
errW = 20
}
errMsg := limitStr(site.LastError, errW)
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(errMsg) + "\n")
}
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 {
var parts []string
limit := 3
if len(stateChanges) < limit {
limit = len(stateChanges)
}
for _, sc := range stateChanges[:limit] {
ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render("→")
from := m.fmtStatusWord(sc.FromStatus)
to := m.fmtStatusWord(sc.ToStatus)
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
if sc.ErrorReason != "" {
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
}
parts = append(parts, entry)
}
b.WriteString(" " + strings.Join(parts, m.st.subtleStyle.Render(" · ")) + "\n")
}
if len(hist.Latencies) > 0 {
sparkW := width - 30
if sparkW < 10 {
sparkW = 10
}
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
}
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
minMs := hist.Latencies[0].Milliseconds()
maxMs := hist.Latencies[0].Milliseconds()
var sumMs int64
for _, l := range hist.Latencies {
ms := l.Milliseconds()
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
}
avgMs := sumMs / int64(len(hist.Latencies))
stats := fmt.Sprintf("Min %s Avg %s Max %s",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
b.WriteString(" " + spark + " " + stats + "\n")
}
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
b.WriteString(" " + keys + "\n")
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
}
func (m Model) fmtStatusWord(status string) string {
switch status {
case "DOWN":
return m.st.dangerStyle.Render("DOWN")
case "UP":
return m.st.specialStyle.Render("UP")
default:
return m.st.subtleStyle.Render(status)
}
}