Compare commits

...

14 Commits

Author SHA1 Message Date
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
36 changed files with 450 additions and 189 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: 80 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 804 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: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 214 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 216 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.
-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)
} }
} }
+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)
} }
+1 -1
View File
@@ -204,7 +204,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 {
+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 {
+12 -12
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),
+13 -2
View File
@@ -181,7 +181,18 @@ func (m Model) viewDashboard() string {
if contentHeight < 1 { if contentHeight < 1 {
contentHeight = 1 contentHeight = 1
} }
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
contentLines := lipgloss.Height(content)
var paddedContent string
if contentLines < contentHeight {
topPad := (contentHeight - contentLines) / 3
paddedContent = lipgloss.NewStyle().
PaddingTop(topPad).
Height(contentHeight).MaxHeight(contentHeight).
Render(content)
} else {
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, paddedContent, footer))
} }
@@ -194,7 +205,7 @@ type tabEntry struct {
func (m Model) renderTabBar(stats dashboardStats) string { func (m Model) renderTabBar(stats dashboardStats) string {
tabs := []tabEntry{ tabs := []tabEntry{
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount}, {"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
{"Alerts", len(m.alerts), 0}, {"Alerts", len(m.alerts), 0},
{"Logs", 0, 0}, {"Logs", 0, 0},
{"Nodes", len(m.nodes), stats.offlineNodes}, {"Nodes", len(m.nodes), stats.offlineNodes},
+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 {