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 ─────────────────────────────────── # ─── 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 ────────────────────────────────────────────────── # ─── Core ──────────────────────────────────────────────────
UPTOP_PORT=23234 # SSH server port 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_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth # UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json # 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? label: What happened?
description: Include what you expected to happen instead. description: Include what you expected to happen instead.
placeholder: | 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. I expected it to keep running and display monitor status.
validations: validations:
required: true required: true
@@ -25,7 +25,7 @@ body:
attributes: attributes:
label: Steps to reproduce label: Steps to reproduce
placeholder: | placeholder: |
1. Run `uptop serve` 1. Run `uptop`
2. Wait ~10 seconds 2. Wait ~10 seconds
3. TUI crashes with panic 3. TUI crashes with panic
validations: validations:
@@ -37,7 +37,7 @@ body:
description: Output of `uptop version`, OS, terminal. Paste any errors below. description: Output of `uptop version`, OS, terminal. Paste any errors below.
render: shell render: shell
placeholder: | placeholder: |
uptop version 2026.06.1 uptop 0.1.0 (abc1234, 2026-06-17)
OS: Debian 13 OS: Debian 13
Terminal: Ghostty Terminal: Ghostty
+2
View File
@@ -49,9 +49,11 @@ jobs:
version: "~> v2" version: "~> v2"
args: release --clean --release-notes=/tmp/release-notes.md args: release --clean --release-notes=/tmp/release-notes.md
env: env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
GORELEASER_FORCE_TOKEN: gitea GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_API_URL: http://gitea:3000/api/v1 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, # GitHub release relaying is handled by .github/workflows/mirror-release.yml,
# which runs on GitHub Actions when the push mirror delivers the tag and # 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="lerkolabs/uptop:${TAG}"
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}" 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 # :latest only for real releases — rc rehearsal tags must not move it
if [ "${{ github.ref_type }}" = "tag" ]; then if [ "${{ github.ref_type }}" = "tag" ]; then
case "$TAG" in case "$TAG" in
*-*) ;; *-*) ;;
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;; *)
TAGS="${TAGS},lerkolabs/uptop:latest"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:latest"
;;
esac esac
fi fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT" echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
@@ -56,6 +61,13 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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 # 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 # the multi-arch push (amd64 layers come from the builder cache, so the
# second build only adds the arm64 work). # 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 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 tmp
*.local.json *.local.json
*.local.md *.local.md
data/
.env .env
vhs
+18
View File
@@ -59,6 +59,24 @@ nfpms:
dst: /usr/share/doc/uptop/LICENSE dst: /usr/share/doc/uptop/LICENSE
type: doc 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 # Changelog generation must stay enabled: the --release-notes flag is consumed
# by the changelog pipe, so disabling it silently drops the git-cliff notes # 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 # (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 --from=builder /app/uptop .
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/ COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
ENV UPTOP_DB_TYPE=sqlite ENV UPTOP_DB_TYPE=sqlite
ENV UPTOP_DB_DSN=/data/uptop.db ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPTOP_KEYS=/data/authorized_keys ENV UPTOP_KEYS=/data/authorized_keys
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519 ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
ENV UPTOP_PORT=23234 ENV UPTOP_PORT=23234
EXPOSE 23234 EXPOSE 8080 23234
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/health || exit 1 CMD wget -qO- http://localhost:8080/api/health || exit 1
USER uptop USER uptop
+23 -10
View File
@@ -1,16 +1,18 @@
<div align="center"> <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>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>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
<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> <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>
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"> <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/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/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
</p> </p>
<img src="assets/monitors.png" alt="uptop monitors view" width="800"> <img src="assets/demo.gif" alt="uptop demo" width="800">
</div> </div>
## What is this ## 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 - **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 - **Config as code** — define monitors in YAML, apply declaratively, version control your setup
- **HA clustering** — leader/follower with automatic failover - **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 - **Public status page** — HTML + JSON, toggle with an env var
- **SQLite or Postgres** — SQLite for single-node, Postgres for production - **SQLite or Postgres** — SQLite for single-node, Postgres for production
- **Uptime Kuma import** — migrate from Kuma with one command - **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> <table>
<tr> <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> <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> <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/logs.png" alt="logs view" width="400"></td>
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
</tr> </tr>
<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> </tr>
</table> </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 ## Quick start
```bash ```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_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses | | `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup | | `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)) | | `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. 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 | | Check history | newest 1,000 checks per monitor |
| State changes (UP/DOWN transitions) | newest 5,000 per monitor | | State changes (UP/DOWN transitions) | newest 5,000 per monitor |
| Logs | newest 200 entries | | 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. 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 return
} }
if mv := info.Main.Version; mv != "" && mv != "(devel)" { 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 { for _, s := range info.Settings {
switch s.Key { switch s.Key {
@@ -77,17 +84,37 @@ func main() {
case "export": case "export":
runExport(os.Args[2:]) runExport(os.Args[2:])
return return
case "version", "--version", "-v": case "version", "--version", "-v", "-version":
printVersion() printVersion()
return return
case "migrate-secrets": case "migrate-secrets":
runMigrateSecrets(os.Args[2:]) runMigrateSecrets(os.Args[2:])
return return
case "help", "--help", "-h":
printUsage()
return
case "serve":
runServe(os.Args[2:])
return
} }
} }
runServe(os.Args[1:]) 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() { func printVersion() {
out := "uptop " + version out := "uptop " + version
var meta []string var meta []string
@@ -186,7 +213,7 @@ func runApply(args []string) {
filePath := fs.String("f", "", "Path to YAML config file (required)") filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying") dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") 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") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
@@ -219,7 +246,7 @@ func runApply(args []string) {
func runExport(args []string) { func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError) fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)") 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") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
@@ -239,7 +266,7 @@ func runExport(args []string) {
func runMigrateSecrets(args []string) { func runMigrateSecrets(args []string) {
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError) 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") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) _ = fs.Parse(args)
@@ -331,7 +358,7 @@ func runServe(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError) fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", cfg.Port, "SSH Port") 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") flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data") demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
+4
View File
@@ -5,6 +5,8 @@ services:
leader: leader:
image: lerkolabs/uptop:latest image: lerkolabs/uptop:latest
container_name: uptop-leader container_name: uptop-leader
sysctls:
- net.ipv4.ping_group_range=0 2147483647
ports: ports:
- "23234:23234" # SSH - "23234:23234" # SSH
- "8080:8080" # HTTP - "8080:8080" # HTTP
@@ -40,6 +42,8 @@ services:
follower: follower:
image: lerkolabs/uptop:latest image: lerkolabs/uptop:latest
container_name: uptop-follower container_name: uptop-follower
sysctls:
- net.ipv4.ping_group_range=0 2147483647
ports: ports:
- "23233:23234" # SSH (Mapped to different host port) - "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (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_TYPE=postgres
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable - 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_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Dev Infrastructure Status - UPTOP_STATUS_TITLE=Dev Infrastructure Status
+6
View File
@@ -1,6 +1,8 @@
services: services:
leader: leader:
image: lerkolabs/uptop:latest image: lerkolabs/uptop:latest
sysctls:
- net.ipv4.ping_group_range=0 2147483647
environment: environment:
- UPTOP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
@@ -12,6 +14,8 @@ services:
probe-us-east: probe-us-east:
image: lerkolabs/uptop:latest image: lerkolabs/uptop:latest
healthcheck:
disable: true
environment: environment:
- UPTOP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=us-east-1 - UPTOP_NODE_ID=us-east-1
@@ -24,6 +28,8 @@ services:
probe-eu-west: probe-eu-west:
image: lerkolabs/uptop:latest image: lerkolabs/uptop:latest
healthcheck:
disable: true
environment: environment:
- UPTOP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=eu-west-1 - UPTOP_NODE_ID=eu-west-1
+2
View File
@@ -8,6 +8,8 @@ services:
- ALL - ALL
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
sysctls:
- net.ipv4.ping_group_range=0 2147483647
tmpfs: tmpfs:
- /tmp - /tmp
ports: ports:
+2 -4
View File
@@ -47,13 +47,11 @@ Probes are lightweight, stateless nodes that run checks from different locations
| Node | Variable | Value | | Node | Variable | Value |
|------|----------|-------| |------|----------|-------|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret | | 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_CLUSTER_MODE` | `probe` |
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL | | Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) | | 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. 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 ## 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. - 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. - 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 Version string
} }
type LogEntry struct {
Message string
CreatedAt time.Time
}
// AlertHealthRecord is the persisted send health of an alert channel. It lets the // AlertHealthRecord is the persisted send health of an alert channel. It lets the
// "last sent" / health indicators survive restarts instead of resetting to "never". // "last sent" / health indicators survive restarts instead of resetting to "never".
type AlertHealthRecord struct { type AlertHealthRecord struct {
+13 -11
View File
@@ -40,7 +40,7 @@ type Engine struct {
liveState map[int]models.Site liveState map[int]models.Site
logMu sync.RWMutex logMu sync.RWMutex
logStore []string logStore []models.LogEntry
activeMu sync.RWMutex activeMu sync.RWMutex
isActive bool isActive bool
@@ -152,11 +152,13 @@ func fmtDurationShort(d time.Duration) string {
// appendLog adds a timestamped entry to the in-memory ring buffer and returns // appendLog adds a timestamped entry to the in-memory ring buffer and returns
// it. It never touches the database, so it is safe to call from the db-write // it. It never touches the database, so it is safe to call from the db-write
// drop/error path without recursing back through the write queue. // drop/error path without recursing back through the write queue.
func (e *Engine) appendLog(msg string) string { func (e *Engine) appendLog(msg string) models.LogEntry {
ts := time.Now().Format("15:04:05") entry := models.LogEntry{
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg)) Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock() e.logMu.Lock()
e.logStore = append([]string{entry}, e.logStore...) e.logStore = append([]models.LogEntry{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries { if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries] e.logStore = e.logStore[:maxLogEntries]
} }
@@ -166,7 +168,7 @@ func (e *Engine) appendLog(msg string) string {
func (e *Engine) AddLog(msg string) { func (e *Engine) AddLog(msg string) {
entry := e.appendLog(msg) entry := e.appendLog(msg)
e.enqueueWrite(writeLog{message: entry}) e.enqueueWrite(writeLog{message: entry.Message})
} }
// enqueueWrite hands a persistence task to the writer goroutine without // enqueueWrite hands a persistence task to the writer goroutine without
@@ -246,16 +248,16 @@ func (e *Engine) Stop() {
} }
func (e *Engine) InitLogs() { func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries) entries, err := e.db.LoadLogs(context.Background(), maxLogEntries)
if err != nil { if err != nil {
return return
} }
if len(logs) == 0 { if len(entries) == 0 {
return return
} }
e.logMu.Lock() e.logMu.Lock()
defer e.logMu.Unlock() defer e.logMu.Unlock()
e.logStore = logs e.logStore = entries
} }
// InitAlertHealth restores persisted alert send health so the dashboard shows real // InitAlertHealth restores persisted alert send health so the dashboard shows real
@@ -278,10 +280,10 @@ func (e *Engine) InitAlertHealth() {
} }
} }
func (e *Engine) GetLogs() []string { func (e *Engine) GetLogs() []models.LogEntry {
e.logMu.RLock() e.logMu.RLock()
defer e.logMu.RUnlock() defer e.logMu.RUnlock()
logs := make([]string, len(e.logStore)) logs := make([]models.LogEntry, len(e.logStore))
copy(logs, e.logStore) copy(logs, e.logStore)
return logs return logs
} }
+9 -6
View File
@@ -25,7 +25,7 @@ type mockStore struct {
sites []models.SiteConfig sites []models.SiteConfig
alerts map[int]models.AlertConfig alerts map[int]models.AlertConfig
maintenance map[int]bool maintenance map[int]bool
logs []string logs []models.LogEntry
history map[int][]models.CheckRecord history map[int][]models.CheckRecord
savedChecks []savedCheck savedChecks []savedCheck
savedLogs []string savedLogs []string
@@ -103,7 +103,7 @@ func (m *mockStore) SaveLog(_ context.Context, msg string) error {
return nil return nil
} }
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) {
return m.logs, nil return m.logs, nil
} }
@@ -330,7 +330,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
logs := e.GetLogs() logs := e.GetLogs()
found := false found := false
for _, l := range logs { for _, l := range logs {
if containsStr(l, "suppressed") { if containsStr(l.Message, "suppressed") {
found = true found = true
break break
} }
@@ -973,14 +973,17 @@ func TestAddLog_PrependAndCap(t *testing.T) {
if len(logs) != 100 { if len(logs) != 100 {
t.Errorf("expected 100 logs, got %d", len(logs)) t.Errorf("expected 100 logs, got %d", len(logs))
} }
if !containsStr(logs[0], "log-104") { if !containsStr(logs[0].Message, "log-104") {
t.Errorf("expected newest log first, got %s", logs[0]) t.Errorf("expected newest log first, got %s", logs[0].Message)
} }
} }
func TestInitLogs_LoadsFromDB(t *testing.T) { func TestInitLogs_LoadsFromDB(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.logs = []string{"old-log-1", "old-log-2"} ms.logs = []models.LogEntry{
{Message: "old-log-1"},
{Message: "old-log-2"},
}
e := newTestEngine(ms) e := newTestEngine(ms)
e.InitLogs() e.InitLogs()
-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) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return 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.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
} }
+2 -2
View File
@@ -219,8 +219,8 @@ func TestHealth_WrongSecret(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 401 { if resp.StatusCode != 200 {
t.Errorf("expected 401, got %d", resp.StatusCode) t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
} }
} }
+15 -8
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -554,21 +555,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
return err return err
} }
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) { func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit) rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var logs []string var entries []models.LogEntry
for rows.Next() { for rows.Next() {
var msg string var e models.LogEntry
if err := rows.Scan(&msg); err != nil { if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
return logs, err return entries, err
} }
logs = append(logs, msg) // Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
if len(e.Message) > 3 && e.Message[0] == '[' {
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
e.Message = strings.TrimSpace(e.Message[idx+1:])
}
}
entries = append(entries, e)
} }
return logs, rows.Err() return entries, rows.Err()
} }
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
+1 -1
View File
@@ -407,7 +407,7 @@ func TestPruneLogs(t *testing.T) {
// LoadLogs ordering ties when rows share a created_at second). // LoadLogs ordering ties when rows share a created_at second).
present := make(map[string]bool, len(logs)) present := make(map[string]bool, len(logs))
for _, l := range logs { for _, l := range logs {
present[l] = true present[l.Message] = true
} }
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] { if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
t.Error("newest log was pruned") t.Error("newest log was pruned")
+1 -1
View File
@@ -61,7 +61,7 @@ type Store interface {
// Logs // Logs
SaveLog(ctx context.Context, message string) error SaveLog(ctx context.Context, message string) error
LoadLogs(ctx context.Context, limit int) ([]string, error) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
PruneLogs(ctx context.Context) error PruneLogs(ctx context.Context) error
// Maintenance Windows // Maintenance Windows
+2 -2
View File
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
return nil return nil
} }
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil } func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil } func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
if m.GetActiveMaintenanceWindowsFunc != nil { if m.GetActiveMaintenanceWindowsFunc != nil {
+2 -2
View File
@@ -106,7 +106,7 @@ func (m *Model) refreshLive() {
m.sites = ordered m.sites = ordered
m.refreshLogContent() m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 { if m.currentTab == tabMonitors && m.selectedID != 0 {
for i, s := range m.sites { for i, s := range m.sites {
if s.ID == m.selectedID { if s.ID == m.selectedID {
m.cursor = i m.cursor = i
@@ -118,7 +118,7 @@ func (m *Model) refreshLive() {
} }
func (m *Model) syncSelectedID() { 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 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 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 { func dimColor(hex string, brightness float64) lipgloss.Color {
r, g, b := parseHex(hex) r, g, b := parseHex(hex)
f := 0.3 + brightness*0.7 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 { func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style {
if bg != "" { if bg != nil {
return s.Background(bg) return s.Background(bg)
} }
return s return s
} }
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style {
var hex string var base lipgloss.TerminalColor
var t float64 var t float64
switch { switch {
case ms < 200: case ms < 200:
hex = m.st.sparkSuccess base = m.st.sparkSuccess
t = float64(ms) / 200 t = float64(ms) / 200
case ms < 500: case ms < 500:
hex = m.st.sparkWarning base = m.st.sparkWarning
t = float64(ms-200) / 300 t = float64(ms-200) / 300
default: default:
hex = m.st.sparkDanger base = m.st.sparkDanger
t = float64(ms-500) / 1500 t = float64(ms-500) / 1500
if t > 1 { if t > 1 {
t = 1 t = 1
} }
} }
hex := trueColorHex(base)
s := lipgloss.NewStyle().Foreground(dimColor(hex, t)) s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
return withBg(s, bg) 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 { if len(latencies) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) 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() 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 { if len(statuses) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) 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) 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() allSites := m.engine.GetAllSites()
var childStatuses [][]bool var childStatuses [][]bool
for _, s := range allSites { for _, s := range allSites {
+19 -17
View File
@@ -5,10 +5,12 @@ import (
"testing" "testing"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/charmbracelet/lipgloss"
) )
func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_Empty(t *testing.T) {
got := styledModel.latencySparkline(nil, nil, 10, "") got := styledModel.latencySparkline(nil, nil, 10, nil)
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty sparkline should be dots, got %q", 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) { func TestLatencySparkline_SingleValue(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond} latencies := []time.Duration{100 * time.Millisecond}
statuses := []bool{true} statuses := []bool{true}
got := styledModel.latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, nil)
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") 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 latencies[i] = time.Duration(i*50) * time.Millisecond
statuses[i] = true statuses[i] = true
} }
got := styledModel.latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, nil)
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") t.Error("sparkline should not be empty")
} }
@@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
func TestLatencySparkline_RelativeHeight(t *testing.T) { func TestLatencySparkline_RelativeHeight(t *testing.T) {
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond} latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
statuses := []bool{true, true, true} statuses := []bool{true, true, true}
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, "")) out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil))
runes := []rune(out) runes := []rune(out)
if len(runes) < 3 { if len(runes) < 3 {
t.Fatalf("expected 3 runes, got %d", len(runes)) 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) { func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
st := newStyles(themeFlexokiDark) st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00" st.sparkSuccess = lipgloss.Color("#00ff00")
st.sparkWarning = "#ffff00" st.sparkWarning = lipgloss.Color("#ffff00")
st.sparkDanger = "#ff0000" st.sparkDanger = lipgloss.Color("#ff0000")
m := Model{st: st} m := Model{st: st}
green := m.latencyStyle(50, "") green := m.latencyStyle(50, nil)
yellow := m.latencyStyle(300, "") yellow := m.latencyStyle(300, nil)
red := m.latencyStyle(800, "") red := m.latencyStyle(800, nil)
gfg := green.GetForeground() gfg := green.GetForeground()
yfg := yellow.GetForeground() yfg := yellow.GetForeground()
@@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
st := newStyles(themeFlexokiDark) st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00" st.sparkSuccess = lipgloss.Color("#00ff00")
m := Model{st: st} m := Model{st: st}
dim := m.latencyStyle(10, "") dim := m.latencyStyle(10, nil)
bright := m.latencyStyle(190, "") bright := m.latencyStyle(190, nil)
if dim.GetForeground() == bright.GetForeground() { if dim.GetForeground() == bright.GetForeground() {
t.Error("10ms and 190ms should have different brightness within green band") 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) { func TestLatencySparkline_OutputWidth(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond} latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
statuses := []bool{true, true, true} statuses := []bool{true, true, true}
got := styledModel.latencySparkline(latencies, statuses, 5, "") got := styledModel.latencySparkline(latencies, statuses, 5, nil)
count := utf8.RuneCountInString(stripANSI(got)) count := utf8.RuneCountInString(stripANSI(got))
if count != 5 { if count != 5 {
t.Errorf("expected 5 rune-width output, got %d from %q", count, got) 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) { func TestHeartbeatSparkline_Empty(t *testing.T) {
got := styledModel.heartbeatSparkline(nil, 10, "") got := styledModel.heartbeatSparkline(nil, 10, nil)
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty heartbeat should be dots, got %q", 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) { func TestHeartbeatSparkline_Mixed(t *testing.T) {
statuses := []bool{true, false, true, true, false} statuses := []bool{true, false, true, true, false}
got := styledModel.heartbeatSparkline(statuses, 5, "") got := styledModel.heartbeatSparkline(statuses, 5, nil)
if len(got) == 0 { if len(got) == 0 {
t.Error("heartbeat sparkline should not be empty") t.Error("heartbeat sparkline should not be empty")
} }
@@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
statuses := []bool{true, true} statuses := []bool{true, true}
got := styledModel.heartbeatSparkline(statuses, 5, "") got := styledModel.heartbeatSparkline(statuses, 5, nil)
if !strings.Contains(got, "···") { if !strings.Contains(got, "···") {
t.Errorf("should have dot padding for width > data, got %q", 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] nameW := widths[2]
cfgW := widths[4] cfgW := widths[4]
return m.renderTable( tbl := m.renderTable(
headers, headers,
len(m.alerts), len(m.alerts),
func(start, end int) [][]string { func(start, end int) [][]string {
@@ -206,6 +206,25 @@ func (m Model) viewAlertsTab() string {
}, },
widths, nil, 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 { func (m Model) viewAlertDetailPanel() string {
+12 -45
View File
@@ -3,6 +3,8 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type logSeverity int type logSeverity int
@@ -15,8 +17,8 @@ const (
severitySystem severitySystem
) )
func classifyLog(line string) logSeverity { func classifyLog(msg string) logSeverity {
lower := strings.ToLower(line) lower := strings.ToLower(msg)
switch { switch {
case strings.Contains(lower, "confirmed down"), case strings.Contains(lower, "confirmed down"),
strings.Contains(lower, "is down"), strings.Contains(lower, "is down"),
@@ -63,67 +65,32 @@ func (m Model) renderLogTag(sev logSeverity) string {
} }
} }
func (m Model) renderLogLine(line string) string { func (m Model) renderLogLine(entry models.LogEntry) string {
sev := classifyLog(line) sev := classifyLog(entry.Message)
tag := m.renderLogTag(sev) tag := m.renderLogTag(sev)
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
ts := "" return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
msg := line
if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
ts = m.st.subtleStyle.Render(line[1:idx])
msg = strings.TrimSpace(line[idx+1:])
}
}
if ts != "" {
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
}
return fmt.Sprintf(" %s %s", tag, msg)
} }
// refreshLogContent rebuilds the log viewport from the full engine log list,
// filtering before windowing so the entry count and "(n hidden)" reflect all
// logs, not just the visible viewport slice.
func (m *Model) refreshLogContent() { func (m *Model) refreshLogContent() {
var rendered []string var rendered []string
total := 0 total := 0
shown := 0 shown := 0
for _, line := range m.engine.GetLogs() { for _, entry := range m.engine.GetLogs() {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(entry.Message) == "" {
continue continue
} }
total++ total++
sev := classifyLog(line) sev := classifyLog(entry.Message)
if m.logFilterImportant && !isImportantLog(sev) { if m.logFilterImportant && !isImportantLog(sev) {
continue continue
} }
shown++ shown++
rendered = append(rendered, m.renderLogLine(line)) rendered = append(rendered, m.renderLogLine(entry))
} }
m.logTotal = total m.logTotal = total
m.logShown = shown m.logShown = shown
m.logViewport.SetContent(strings.Join(rendered, "\n")) 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] monW := widths[3]
timeW := widths[5] timeW := widths[5]
return m.renderTable( tbl := m.renderTable(
headers, headers,
len(m.maintenanceWindows), len(m.maintenanceWindows),
func(start, end int) [][]string { func(start, end int) [][]string {
@@ -130,6 +130,21 @@ func (m Model) viewMaintTab() string {
widths, widths,
nil, 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 { func (m *Model) initMaintHuhForm() tea.Cmd {
+27 -1
View File
@@ -1,6 +1,8 @@
package tui package tui
import ( import (
"fmt"
"strings"
"time" "time"
) )
@@ -20,7 +22,7 @@ func (m Model) viewNodesTab() string {
} }
nameW := widths[0] nameW := widths[0]
return m.renderTable( tbl := m.renderTable(
headers, headers,
len(m.nodes), len(m.nodes),
func(start, end int) [][]string { func(start, end int) [][]string {
@@ -48,6 +50,30 @@ func (m Model) viewNodesTab() string {
widths, widths,
nil, 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 { 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 widths []int
var fixed int var fixed int
cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
for _, c := range siteColumns { for _, c := range siteColumns {
if c.minTerm > 0 && m.termWidth < c.minTerm { if c.minTerm > 0 && cw < c.minTerm {
continue continue
} }
active = append(active, c.key) active = append(active, c.key)
@@ -104,7 +108,7 @@ func (m Model) computeLayout() tableLayout {
numCols := len(headers) numCols := len(headers)
borderOverhead := 2 + (numCols - 1) borderOverhead := 2 + (numCols - 1)
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed avail := cw - chromePadH - 2 - borderOverhead - fixed
if avail < 20 { if avail < 20 {
avail = 20 avail = 20
} }
@@ -204,7 +208,7 @@ func (m Model) viewSitesTab() string {
for i := start; i < end; i++ { for i := start; i < end; i++ {
site := m.sites[i] site := m.sites[i]
rowIdx := i - start rowIdx := i - start
var rowBg lipgloss.Color var rowBg lipgloss.TerminalColor
if i == m.cursor { if i == m.cursor {
rowBg = m.theme.SelectedBg rowBg = m.theme.SelectedBg
} else if rowIdx%2 == 1 { } else if rowIdx%2 == 1 {
+1 -1
View File
@@ -116,7 +116,7 @@ func (m *Model) submitUserForm() tea.Cmd {
st := m.store st := m.store
id := m.editID id := m.editID
username, key, role := d.Username, d.PublicKey, d.Role username, key, role := d.Username, d.PublicKey, d.Role
m.state = stateUsers m.state = stateDashboard
if id > 0 { if id > 0 {
return writeCmd("Update user", func() error { return writeCmd("Update user", func() error {
return st.UpdateUser(context.Background(), id, username, key, role) return st.UpdateUser(context.Background(), id, username, key, role)
+12 -5
View File
@@ -13,7 +13,11 @@ const (
) )
func (m Model) isWide() bool { 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 { 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 borderOverhead := 2 + len(colWidths) - 1
tableWidth := colTotal + borderOverhead tableWidth := colTotal + borderOverhead
maxWidth := m.termWidth - chromePadH - 2 cw := m.contentWidth
if cw == 0 {
cw = m.termWidth
}
maxWidth := cw - chromePadH - 2
if tableWidth > maxWidth { if tableWidth > maxWidth {
tableWidth = maxWidth tableWidth = maxWidth
} }
@@ -44,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
} }
t := table.New(). t := table.New().
Border(lipgloss.RoundedBorder()). Border(lipgloss.HiddenBorder()).
BorderStyle(m.st.tableBorderStyle).
Width(tableWidth). Width(tableWidth).
Headers(headers...). Headers(headers...).
Rows(rows...). Rows(rows...).
@@ -86,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return base return base
}) })
return "\n" + t.Render() return t.Render()
} }
+110 -102
View File
@@ -5,35 +5,43 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
func cc(hex, ansi string) lipgloss.CompleteColor {
return lipgloss.CompleteColor{
TrueColor: hex,
ANSI256: hex,
ANSI: ansi,
}
}
type Theme struct { type Theme struct {
Name string Name string
// Base layers // Base layers
Bg lipgloss.Color Bg lipgloss.TerminalColor
Surface lipgloss.Color Surface lipgloss.TerminalColor
Panel lipgloss.Color Panel lipgloss.TerminalColor
Border lipgloss.Color Border lipgloss.TerminalColor
// Text // Text
Fg lipgloss.Color Fg lipgloss.TerminalColor
Muted lipgloss.Color Muted lipgloss.TerminalColor
Subtle lipgloss.Color Subtle lipgloss.TerminalColor
// Semantic // Semantic
Success lipgloss.Color Success lipgloss.TerminalColor
Warning lipgloss.Color Warning lipgloss.TerminalColor
Stale lipgloss.Color Stale lipgloss.TerminalColor
Danger lipgloss.Color Danger lipgloss.TerminalColor
Info lipgloss.Color Info lipgloss.TerminalColor
Accent lipgloss.Color Accent lipgloss.TerminalColor
Purple lipgloss.Color Purple lipgloss.TerminalColor
// Table // Table
ZebraBg lipgloss.Color ZebraBg lipgloss.TerminalColor
// Selection // Selection
SelectedFg lipgloss.Color SelectedFg lipgloss.TerminalColor
SelectedBg lipgloss.Color SelectedBg lipgloss.TerminalColor
} }
var themes = []Theme{ var themes = []Theme{
@@ -46,107 +54,107 @@ var themes = []Theme{
var themeFlexokiDark = Theme{ var themeFlexokiDark = Theme{
Name: "Flexoki Dark", Name: "Flexoki Dark",
Bg: "#1C1B1A", Bg: cc("#1C1B1A", ""),
Surface: "#282726", Surface: cc("#282726", ""),
Panel: "#343331", Panel: cc("#343331", ""),
Border: "#575653", Border: cc("#575653", "8"),
Fg: "#CECDC3", Fg: cc("#CECDC3", "15"),
Muted: "#878580", Muted: cc("#878580", "7"),
Subtle: "#6F6E69", Subtle: cc("#6F6E69", "7"),
Success: "#879A39", Success: cc("#879A39", "10"),
Warning: "#D0A215", Warning: cc("#D0A215", "11"),
Stale: "#DA702C", Stale: cc("#DA702C", "3"),
Danger: "#D14D41", Danger: cc("#D14D41", "9"),
Info: "#4385BE", Info: cc("#4385BE", "12"),
Accent: "#3AA99F", Accent: cc("#3AA99F", "14"),
Purple: "#8B7EC8", Purple: cc("#8B7EC8", "13"),
ZebraBg: "#222120", ZebraBg: cc("#222120", ""),
SelectedFg: "#FFFCF0", SelectedFg: cc("#FFFCF0", "15"),
SelectedBg: "#403E3C", SelectedBg: cc("#403E3C", "4"),
} }
var themeTokyoNight = Theme{ var themeTokyoNight = Theme{
Name: "Tokyo Night", Name: "Tokyo Night",
Bg: "#1a1b26", Bg: cc("#1a1b26", ""),
Surface: "#24283b", Surface: cc("#24283b", ""),
Panel: "#292e42", Panel: cc("#292e42", ""),
Border: "#3b4261", Border: cc("#3b4261", "8"),
Fg: "#c0caf5", Fg: cc("#c0caf5", "15"),
Muted: "#a9b1d6", Muted: cc("#a9b1d6", "7"),
Subtle: "#565f89", Subtle: cc("#565f89", "7"),
Success: "#9ece6a", Success: cc("#9ece6a", "10"),
Warning: "#e0af68", Warning: cc("#e0af68", "11"),
Stale: "#ff9e64", Stale: cc("#ff9e64", "3"),
Danger: "#f7768e", Danger: cc("#f7768e", "9"),
Info: "#7aa2f7", Info: cc("#7aa2f7", "12"),
Accent: "#7dcfff", Accent: cc("#7dcfff", "14"),
Purple: "#bb9af7", Purple: cc("#bb9af7", "13"),
ZebraBg: "#1c1d28", ZebraBg: cc("#1c1d28", ""),
SelectedFg: "#c0caf5", SelectedFg: cc("#c0caf5", "15"),
SelectedBg: "#292e42", SelectedBg: cc("#292e42", "4"),
} }
var themeGruvbox = Theme{ var themeGruvbox = Theme{
Name: "Gruvbox", Name: "Gruvbox",
Bg: "#282828", Bg: cc("#282828", ""),
Surface: "#3c3836", Surface: cc("#3c3836", ""),
Panel: "#504945", Panel: cc("#504945", ""),
Border: "#665c54", Border: cc("#665c54", "8"),
Fg: "#ebdbb2", Fg: cc("#ebdbb2", "15"),
Muted: "#bdae93", Muted: cc("#bdae93", "7"),
Subtle: "#7c6f64", Subtle: cc("#7c6f64", "7"),
Success: "#b8bb26", Success: cc("#b8bb26", "10"),
Warning: "#fabd2f", Warning: cc("#fabd2f", "11"),
Stale: "#fe8019", Stale: cc("#fe8019", "3"),
Danger: "#fb4934", Danger: cc("#fb4934", "9"),
Info: "#83a598", Info: cc("#83a598", "12"),
Accent: "#8ec07c", Accent: cc("#8ec07c", "14"),
Purple: "#d3869b", Purple: cc("#d3869b", "13"),
ZebraBg: "#2a2a2a", ZebraBg: cc("#2a2a2a", ""),
SelectedFg: "#fbf1c7", SelectedFg: cc("#fbf1c7", "15"),
SelectedBg: "#504945", SelectedBg: cc("#504945", "4"),
} }
var themeCatppuccinMocha = Theme{ var themeCatppuccinMocha = Theme{
Name: "Catppuccin Mocha", Name: "Catppuccin Mocha",
Bg: "#1e1e2e", Bg: cc("#1e1e2e", ""),
Surface: "#313244", Surface: cc("#313244", ""),
Panel: "#45475a", Panel: cc("#45475a", ""),
Border: "#585b70", Border: cc("#585b70", "8"),
Fg: "#cdd6f4", Fg: cc("#cdd6f4", "15"),
Muted: "#a6adc8", Muted: cc("#a6adc8", "7"),
Subtle: "#6c7086", Subtle: cc("#6c7086", "7"),
Success: "#a6e3a1", Success: cc("#a6e3a1", "10"),
Warning: "#f9e2af", Warning: cc("#f9e2af", "11"),
Stale: "#fab387", Stale: cc("#fab387", "3"),
Danger: "#f38ba8", Danger: cc("#f38ba8", "9"),
Info: "#89b4fa", Info: cc("#89b4fa", "12"),
Accent: "#94e2d5", Accent: cc("#94e2d5", "14"),
Purple: "#cba6f7", Purple: cc("#cba6f7", "13"),
ZebraBg: "#232334", ZebraBg: cc("#232334", ""),
SelectedFg: "#cdd6f4", SelectedFg: cc("#cdd6f4", "15"),
SelectedBg: "#45475a", SelectedBg: cc("#45475a", "4"),
} }
var themeNord = Theme{ var themeNord = Theme{
Name: "Nord", Name: "Nord",
Bg: "#2e3440", Bg: cc("#2e3440", ""),
Surface: "#3b4252", Surface: cc("#3b4252", ""),
Panel: "#434c5e", Panel: cc("#434c5e", ""),
Border: "#4c566a", Border: cc("#4c566a", "8"),
Fg: "#d8dee9", Fg: cc("#d8dee9", "15"),
Muted: "#d8dee9", Muted: cc("#d8dee9", "7"),
Subtle: "#4c566a", Subtle: cc("#4c566a", "7"),
Success: "#a3be8c", Success: cc("#a3be8c", "10"),
Warning: "#ebcb8b", Warning: cc("#ebcb8b", "11"),
Stale: "#d08770", Stale: cc("#d08770", "3"),
Danger: "#bf616a", Danger: cc("#bf616a", "9"),
Info: "#81a1c1", Info: cc("#81a1c1", "12"),
Accent: "#88c0d0", Accent: cc("#88c0d0", "14"),
Purple: "#b48ead", Purple: cc("#b48ead", "13"),
ZebraBg: "#323845", ZebraBg: cc("#323845", ""),
SelectedFg: "#eceff4", SelectedFg: cc("#eceff4", "15"),
SelectedBg: "#434c5e", SelectedBg: cc("#434c5e", "4"),
} }
func (t Theme) HuhTheme() *huh.Theme { func (t Theme) HuhTheme() *huh.Theme {
+48 -23
View File
@@ -30,9 +30,9 @@ type styles struct {
activeTab lipgloss.Style activeTab lipgloss.Style
inactiveTab lipgloss.Style inactiveTab lipgloss.Style
sparkSuccess string sparkSuccess lipgloss.TerminalColor
sparkWarning string sparkWarning lipgloss.TerminalColor
sparkDanger string sparkDanger lipgloss.TerminalColor
tableHeaderStyle lipgloss.Style tableHeaderStyle lipgloss.Style
tableCellStyle lipgloss.Style tableCellStyle lipgloss.Style
@@ -46,23 +46,23 @@ type styles struct {
func newStyles(t Theme) *styles { func newStyles(t Theme) *styles {
return &styles{ return &styles{
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle), subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
specialStyle: lipgloss.NewStyle().Foreground(t.Success), specialStyle: lipgloss.NewStyle().Foreground(t.Success),
warnStyle: lipgloss.NewStyle().Foreground(t.Warning), warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
staleStyle: lipgloss.NewStyle().Foreground(t.Stale), staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger), dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1), 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), sparkSuccess: t.Success,
sparkWarning: string(t.Warning), sparkWarning: t.Warning,
sparkDanger: string(t.Danger), sparkDanger: t.Danger,
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1), tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
tableCellStyle: lipgloss.NewStyle().Padding(0, 1), tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg), 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), tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent), siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
@@ -84,6 +84,24 @@ const (
detailSparkWidth = 40 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 type sessionState int
const ( const (
@@ -102,16 +120,20 @@ const (
) )
type Model struct { type Model struct {
state sessionState state sessionState
currentTab int currentTab int
cursor int settingsSection int
selectedID int cursor int
tableOffset int selectedID int
maxTableRows int tableOffset int
termWidth int maxTableRows int
termHeight int termWidth int
editID int termHeight int
editToken string contentWidth int
focusedPanel int
logScrollOffset int
editID int
editToken string
huhForm *huh.Form huhForm *huh.Form
siteFormData *siteFormData siteFormData *siteFormData
@@ -165,7 +187,7 @@ type Model struct {
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle) lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load tabSeq int // seq of the newest issued tab-data load
// detail-panel state-change history, loaded on enter so View does no DB IO detailOpen bool
detailChanges []models.StateChange detailChanges []models.StateChange
detailChangesSiteID int detailChangesSiteID int
@@ -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{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -210,6 +234,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme), st: newStyles(theme),
detailOpen: detailPref == "true",
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version, version: version,
sparkTooltipIdx: -1, sparkTooltipIdx: -1,
+215 -127
View File
@@ -78,31 +78,28 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
id := m.deleteID id := m.deleteID
var cmd tea.Cmd var cmd tea.Cmd
switch m.deleteTab { switch m.deleteTab {
case 0: case tabMonitors:
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) }) cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
m.engine.RemoveSite(id) m.engine.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case 1: case tabMaint:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
m.adjustCursor(len(m.alerts) - 1)
case 4:
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) }) cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
m.adjustCursor(len(m.maintenanceWindows) - 1) m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5: case tabSettings:
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) }) switch m.settingsSection {
m.adjustCursor(len(m.users) - 1) 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.refreshLive()
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
return m, cmd return m, cmd
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 {
m.state = stateUsers
}
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
} }
@@ -117,9 +114,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg.String() == "esc" { if keyMsg.String() == "esc" {
m.huhForm = nil m.huhForm = nil
m.state = stateDashboard m.state = stateDashboard
if m.currentTab == 5 {
m.state = stateUsers
}
return m, nil return m, nil
} }
} }
@@ -148,11 +142,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
const detailInlineHeight = 8
func (m *Model) recalcLayout() { func (m *Model) recalcLayout() {
chrome := chromeBase chrome := chromeBase
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
if m.detailOpen && m.currentTab == tabMonitors {
chrome += detailInlineHeight
}
m.maxTableRows = m.termHeight - chrome m.maxTableRows = m.termHeight - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
@@ -265,7 +264,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { if m.state != stateDashboard {
return m, nil return m, nil
} }
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { 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 return m, nil
} }
if m.state == stateLogs { if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
m.logViewport.ScrollUp(3) m.scrollLogs(-3)
} else { } else {
m.logViewport.ScrollDown(3) m.scrollLogs(3)
} }
return m, nil return m, nil
} }
@@ -301,6 +300,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
@@ -325,7 +327,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.handleSLAKey(msg) return m.handleSLAKey(msg)
case stateAlertDetail: case stateAlertDetail:
return m.handleAlertDetailKey(msg) return m.handleAlertDetailKey(msg)
case stateDashboard, stateLogs, stateUsers: case stateDashboard:
return m.handleDashboardKey(msg) return m.handleDashboardKey(msg)
} }
return m, nil return m, nil
@@ -521,63 +523,84 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleDashboardKey(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() { switch msg.String() {
case "q": case "q":
return m, tea.Quit return m, tea.Quit
case "/": case "/":
if m.currentTab == 0 { if m.currentTab == tabMonitors {
m.filterMode = true m.filterMode = true
m.recalcLayout() m.recalcLayout()
return m, nil return m, nil
} }
case "f":
if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
return m, nil
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "pgup", "pgdown": case "left":
if m.state == stateLogs { if m.currentTab == tabSettings {
m.logViewport, cmd = m.logViewport.Update(msg) m.switchSettingsSection(m.settingsSection - 1)
return m, cmd }
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": case "up", "k":
if m.state == stateLogs { if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.logViewport.ScrollUp(1) m.scrollLogs(-1)
} else if m.cursor > 0 { return m, nil
}
if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
m.tableOffset = m.cursor m.tableOffset = m.cursor
} }
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
} }
case "down", "j": case "down", "j":
if m.state == stateLogs { if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
m.logViewport.ScrollDown(1) m.scrollLogs(1)
} else { return m, nil
max := m.currentListLen() - 1 }
if m.cursor < max { max := m.currentListLen() - 1
m.cursor++ if m.cursor < max {
if m.cursor >= m.tableOffset+m.maxTableRows { m.cursor++
m.tableOffset++ if m.cursor >= m.tableOffset+m.maxTableRows {
} m.tableOffset++
m.syncSelectedID() }
m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} }
} }
case "n": case "n":
return m.handleNewItem() return m.handleNewItem()
case "e", "enter": case "enter":
if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m.handleEditItem()
case "e":
return m.handleEditItem() return m.handleEditItem()
case "t": case "t":
if m.currentTab == 1 && len(m.alerts) > 0 { if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
a := m.alerts[m.cursor] a := m.alerts[m.cursor]
return m, m.testAlertCmd(a.ID, a.Name) return m, m.testAlertCmd(a.ID, a.Name)
} }
case " ": 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 gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid] m.collapsed[gid] = !m.collapsed[gid]
payload := collapsedJSON(m.collapsed) payload := collapsedJSON(m.collapsed)
@@ -588,7 +611,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "p": case "p":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
id := m.sites[m.cursor].ID id := m.sites[m.cursor].ID
paused := m.engine.ToggleSitePause(id) paused := m.engine.ToggleSitePause(id)
st := m.store st := m.store
@@ -598,14 +621,62 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}) })
} }
case "i": case "i":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == tabMonitors && len(m.sites) > 0 {
m.state = stateDetail m.detailOpen = !m.detailOpen
return m, m.loadDetailCmd(m.sites[m.cursor].ID) m.recalcLayout()
} else if m.currentTab == 1 && len(m.alerts) > 0 { 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 m.state = stateAlertDetail
} }
case "esc":
if m.currentTab == tabMonitors {
if m.focusedPanel != panelMonitors {
m.focusedPanel = panelMonitors
} else if m.detailOpen {
m.detailOpen = false
m.recalcLayout()
st := m.store
return m, writeCmd("Save detail preference", func() error {
return st.SetPreference(context.Background(), "detail_open", "false")
})
}
}
case "h":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
site := m.sites[m.cursor]
m.historySiteName = site.Name
m.historySiteID = site.ID
m.historyChanges = nil
m.historyViewport = viewport.New(
m.termWidth-chromePadH,
m.termHeight-10,
)
m.historyViewport.SetContent("\n Loading state history...")
m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
}
case "s":
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor])
}
case "x": case "x":
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 { if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
mw := m.maintenanceWindows[m.cursor] mw := m.maintenanceWindows[m.cursor]
now := time.Now() now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
@@ -637,19 +708,22 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
m.editID = 0 m.editID = 0
m.editToken = "" m.editToken = ""
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
m.state = stateFormSite m.state = stateFormSite
return m, m.initSiteHuhForm() return m, m.initSiteHuhForm()
case 1: case tabMaint:
m.state = stateFormAlert
return m, m.initAlertHuhForm()
case 4:
m.state = stateFormMaint m.state = stateFormMaint
return m, m.initMaintHuhForm() return m, m.initMaintHuhForm()
case 5: case tabSettings:
if m.isAdmin { switch m.settingsSection {
m.state = stateFormUser case sectionAlerts:
return m, m.initUserHuhForm() m.state = stateFormAlert
return m, m.initAlertHuhForm()
case sectionUsers:
if m.isAdmin {
m.state = stateFormUser
return m, m.initUserHuhForm()
}
} }
} }
return m, nil return m, nil
@@ -657,24 +731,27 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) { func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.editID = m.sites[m.cursor].ID m.editID = m.sites[m.cursor].ID
m.editToken = m.sites[m.cursor].Token m.editToken = m.sites[m.cursor].Token
m.state = stateFormSite m.state = stateFormSite
return m, m.initSiteHuhForm() return m, m.initSiteHuhForm()
} }
case 1: case tabSettings:
if len(m.alerts) > 0 { switch m.settingsSection {
m.editID = m.alerts[m.cursor].ID case sectionAlerts:
m.state = stateFormAlert if len(m.alerts) > 0 {
return m, m.initAlertHuhForm() m.editID = m.alerts[m.cursor].ID
} m.state = stateFormAlert
case 5: return m, m.initAlertHuhForm()
if m.isAdmin && len(m.users) > 0 { }
m.editID = m.users[m.cursor].ID case sectionUsers:
m.state = stateFormUser if m.isAdmin && len(m.users) > 0 {
return m, m.initUserHuhForm() m.editID = m.users[m.cursor].ID
m.state = stateFormUser
return m, m.initUserHuhForm()
}
} }
} }
return m, nil return m, nil
@@ -682,43 +759,43 @@ func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) { func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
if len(m.sites) > 0 { if len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name m.deleteName = m.sites[m.cursor].Name
m.deleteTab = 0 m.deleteTab = tabMonitors
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
case 1: case tabMaint:
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:
if len(m.maintenanceWindows) > 0 { if len(m.maintenanceWindows) > 0 {
m.deleteID = m.maintenanceWindows[m.cursor].ID m.deleteID = m.maintenanceWindows[m.cursor].ID
m.deleteName = m.maintenanceWindows[m.cursor].Title m.deleteName = m.maintenanceWindows[m.cursor].Title
m.deleteTab = 4 m.deleteTab = tabMaint
m.state = stateConfirmDelete m.state = stateConfirmDelete
} }
case 5: case tabSettings:
if m.isAdmin && len(m.users) > 0 { switch m.settingsSection {
m.deleteID = m.users[m.cursor].ID case sectionAlerts:
m.deleteName = m.users[m.cursor].Username if len(m.alerts) > 0 {
m.deleteTab = 5 m.deleteID = m.alerts[m.cursor].ID
m.state = stateConfirmDelete 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 = tabSettings
m.state = stateConfirmDelete
}
} }
} }
return m, nil return m, nil
} }
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
tabCount := 5 tabCount := tabSettings + 1
if m.isAdmin {
tabCount = 6
}
for i := 0; i < tabCount; i++ { for i := 0; i < tabCount; i++ {
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
m.switchTab(i) 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() prefix, listLen := m.currentZonePrefix()
end := m.tableOffset + m.maxTableRows end := m.tableOffset + m.maxTableRows
if end > listLen { 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) { if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
m.cursor = i m.cursor = i
m.syncSelectedID() m.syncSelectedID()
if m.detailOpen && m.currentTab == tabMonitors {
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
}
return m, nil return m, nil
} }
} }
@@ -743,24 +835,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
func (m *Model) switchTab(idx int) { func (m *Model) switchTab(idx int) {
maxTabs := 4 maxTabs := tabSettings
if m.isAdmin {
maxTabs = 5
}
if idx > maxTabs { if idx > maxTabs {
idx = 0 idx = 0
} }
m.currentTab = idx m.currentTab = idx
m.cursor = 0 m.cursor = 0
m.tableOffset = 0 m.tableOffset = 0
switch idx { m.state = stateDashboard
case 2:
m.state = stateLogs
case 5:
m.state = stateUsers
default:
m.state = stateDashboard
}
} }
func (m *Model) adjustCursor(_ int) { func (m *Model) adjustCursor(_ int) {
@@ -791,30 +873,36 @@ func (m *Model) submitForm() tea.Cmd {
func (m Model) currentListLen() int { func (m Model) currentListLen() int {
switch m.currentTab { switch m.currentTab {
case 1: case tabMonitors:
return len(m.alerts)
case 3:
return len(m.nodes)
case 4:
return len(m.maintenanceWindows)
case 5:
return len(m.users)
default:
return len(m.sites) 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) { func (m Model) currentZonePrefix() (string, int) {
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
return "site", len(m.sites) return "site", len(m.sites)
case 1: case tabMaint:
return "alert", len(m.alerts)
case 4:
return "maint", len(m.maintenanceWindows) return "maint", len(m.maintenanceWindows)
case 5: case tabSettings:
return "user", len(m.users) switch m.settingsSection {
default: case sectionAlerts:
return "site", 0 return "alert", len(m.alerts)
case sectionUsers:
return "user", len(m.users)
}
} }
return "site", 0
} }
+1 -1
View File
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
mm := updated.(Model) mm := updated.(Model)
found := false found := false
for _, line := range mm.engine.GetLogs() { for _, line := range mm.engine.GetLogs() {
if strings.Contains(line, "Delete site failed: boom") { if strings.Contains(line.Message, "Delete site failed: boom") {
found = true found = true
} }
} }
+88 -36
View File
@@ -151,20 +151,52 @@ func (m Model) viewDashboard() string {
var content string var content string
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
content = m.viewSitesTab() showSidebar := m.termWidth >= wideBreakpoint
case 1: if showSidebar {
content = m.viewAlertsTab() availW := m.termWidth - chromePadH
case 2: leftW := availW * 70 / 100
content = m.viewLogsTab() rightW := availW - leftW
case 3: m.contentWidth = leftW - 2
content = m.viewNodesTab() monitors := m.viewSitesTab()
case 4: monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
content = m.viewMaintTab() sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
case 5: logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
if m.isAdmin { top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
content = m.viewUsersTab() 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) content = strings.TrimSpace(content)
@@ -177,13 +209,19 @@ func (m Model) viewDashboard() string {
availHeight = 5 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 { if contentHeight < 1 {
contentHeight = 1 contentHeight = 1
} }
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content) 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 { type tabEntry struct {
@@ -193,15 +231,15 @@ type tabEntry struct {
} }
func (m Model) renderTabBar(stats dashboardStats) string { func (m Model) renderTabBar(stats dashboardStats) string {
tabs := []tabEntry{ settingsCount := len(m.alerts) + len(m.nodes)
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount}, settingsWarn := stats.offlineNodes
{"Alerts", len(m.alerts), 0},
{"Logs", 0, 0},
{"Nodes", len(m.nodes), stats.offlineNodes},
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
}
if m.isAdmin { 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) countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
@@ -264,24 +302,38 @@ func (m Model) renderFooter(stats dashboardStats) string {
var keys string var keys string
switch m.currentTab { switch m.currentTab {
case 0: case tabMonitors:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" if m.focusedPanel == panelLogs {
case 1: keys = "[↑/↓]Scroll [l/Esc]Back [T]Theme [q]Quit"
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" } else if m.detailOpen {
case 2: keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit" } else {
case 4: 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" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5: case tabSettings:
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit" 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: default:
keys = "[T]Theme [Tab]Switch [q]Quit" keys = "[T]Theme [Tab]Switch [q]Quit"
} }
ver := m.st.subtleStyle.Render("v" + m.version) ver := m.st.subtleStyle.Render("v" + m.version)
footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == 0 { if m.filterText != "" && m.currentTab == tabMonitors {
footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver 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 { if site.ParentID > 0 {
for _, s := range m.sites { for _, s := range m.sites {
if s.ID == site.ParentID { 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 break
} }
} }
} }
if breadcrumb == "" { 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(breadcrumb + "\n")
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
@@ -215,7 +215,7 @@ func (m Model) viewDetailPanel() string {
b.WriteString(m.divider() + "\n") b.WriteString(m.divider() + "\n")
if site.Type == "push" { 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 { if len(hist.Statuses) > 0 {
up := 0 up := 0
for _, s := range hist.Statuses { for _, s := range hist.Statuses {
@@ -228,7 +228,7 @@ func (m Model) viewDetailPanel() string {
up, len(hist.Statuses)) up, len(hist.Statuses))
} }
} else { } 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 var minL, maxL, total time.Duration
count := 0 count := 0
for i, l := range hist.Latencies { 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)
}
}