5 Commits

Author SHA1 Message Date
lerko d076fa6b26 ci(release): skip GitHub relay when GH_MIRROR_TOKEN absent
CI / test (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
CI / vulncheck (pull_request) Has been cancelled
Relay is on hold until after the rc dress rehearsal — without the
secret the step exits cleanly instead of failing the release run.
Adding the secret later enables it with no workflow change.
2026-06-12 15:08:11 -04:00
lerko 5145237e88 test(importer): cover malformed Kuma backup input
Importer parses untrusted JSON on the migration onboarding path with no
coverage. Add malformed-input table (truncated, wrong types, null
lists), notification config edge cases, and field-mapping checks.
2026-06-12 14:30:48 -04:00
lerko fc8cbcafa5 docs(monitor): document before-Start contract on engine setters 2026-06-12 14:30:48 -04:00
lerko 6df9b52033 fix(tui): apply log filter to full log list, not viewport window
viewLogsTab filtered logViewport.View() — the visible window — so the
entry count showed the window size and hidden lines reappeared while
scrolling. Filter and render now happen at content-set time from
engine.GetLogs(); the view only reads stored counts.
2026-06-12 14:30:48 -04:00
lerko 49f419896c ci(release): relay release artifacts to GitHub mirror
GoReleaser publishes to exactly one SCM (Gitea); the push mirror carries
refs but not releases, so GitHub Releases — where the README points —
stayed empty. After the Gitea release, wait for the mirrored tag and
create the GitHub release with the same artifacts and notes.

Needs new Gitea secret GH_MIRROR_TOKEN (GitHub PAT with repo scope).
GITHUB_TOKEN is reserved by Gitea Actions, hence the different name.
2026-06-12 14:30:48 -04:00
27 changed files with 346 additions and 605 deletions
+34 -3
View File
@@ -53,6 +53,37 @@ jobs:
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
# GitHub release relaying is handled by .github/workflows/mirror-release.yml, # GoReleaser publishes to exactly one SCM (Gitea). The push mirror
# which runs on GitHub Actions when the push mirror delivers the tag and # carries git refs but not release artifacts, so relay the release to
# copies this run's Gitea release assets — no PAT needed on this side. # the GitHub mirror — README install links point there.
- name: Mirror release to GitHub
env:
GH_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "GH_MIRROR_TOKEN not set — skipping GitHub release relay"
exit 0
fi
apk add --no-cache github-cli
TAG="${{ github.ref_name }}"
# Nudge the push mirror, then wait for the tag to land on GitHub.
curl -sf -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
"http://gitea:3000/api/v1/repos/lerkolabs/uptop/push_mirrors-sync" || true
for i in $(seq 1 30); do
if gh api "repos/lerkolabs/uptop/git/ref/tags/${TAG}" >/dev/null 2>&1; then
break
fi
sleep 10
done
PRERELEASE=""
case "$TAG" in *-*) PRERELEASE="--prerelease";; esac
gh release create "$TAG" \
--repo lerkolabs/uptop \
--verify-tag \
--title "$TAG" \
--notes-file /tmp/release-notes.md \
$PRERELEASE \
dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt
+6 -27
View File
@@ -35,12 +35,8 @@ jobs:
TAGS="lerkolabs/uptop:${TAG}" TAGS="lerkolabs/uptop:${TAG}"
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}" TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
# :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 TAGS="${TAGS},lerkolabs/uptop:latest"
*-*) ;;
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;;
esac
fi fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT" echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
@@ -56,26 +52,6 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
# Scan must gate the push: build amd64 locally, scan it, and only then run
# the multi-arch push (amd64 layers come from the builder cache, so the
# second build only adds the arm64 work).
- name: Build for scan (amd64, local)
uses: docker/build-push-action@v5
with:
context: .
load: true
platforms: linux/amd64
tags: uptop-scan:${{ steps.meta.outputs.tag }}
build-args: |
VERSION=${{ steps.meta.outputs.tag }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
- name: Scan image for CVEs
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.114.0
grype uptop-scan:${{ steps.meta.outputs.tag }} --fail-on critical --output table
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
@@ -90,6 +66,11 @@ jobs:
COMMIT=${{ github.sha }} COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }} BUILD_DATE=${{ github.event.head_commit.timestamp }}
- name: Scan image for CVEs
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.114.0
grype lerkolabs/uptop:${{ steps.meta.outputs.tag }} --fail-on critical --output table
- name: Update Docker Hub description - name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4 uses: peter-evans/dockerhub-description@v4
with: with:
@@ -100,7 +81,5 @@ jobs:
- name: Cleanup Docker artifacts - name: Cleanup Docker artifacts
if: always() if: always()
run: | run: |
# the scan image is tagged, so image prune won't catch it
docker image rm "uptop-scan:${{ steps.meta.outputs.tag }}" 2>/dev/null || true
docker image prune -f docker image prune -f
docker builder prune -f --keep-storage=2GB docker builder prune -f --keep-storage=2GB
+7 -19
View File
@@ -19,35 +19,26 @@ jobs:
run: | run: |
API="https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/releases/tags/${TAG}" API="https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/releases/tags/${TAG}"
# 40 x 30s = 20 min: the Gitea release can queue behind the ~18-min for i in $(seq 1 20); do
# Docker job on the single runner. Asset count must hold steady for
# two consecutive polls — GoReleaser uploads one file at a time, and
# mirroring mid-upload would publish a partial asset set.
PREV_COUNT=0
ASSET_COUNT=0
for i in $(seq 1 40); do
if RESPONSE=$(curl -sf "$API" 2>/dev/null); then if RESPONSE=$(curl -sf "$API" 2>/dev/null); then
ASSET_COUNT=$(echo "$RESPONSE" | jq '.assets | length') ASSET_COUNT=$(echo "$RESPONSE" | jq '.assets | length')
if [ "$ASSET_COUNT" -gt 0 ] && [ "$ASSET_COUNT" -eq "$PREV_COUNT" ]; then if [ "$ASSET_COUNT" -gt 0 ]; then
echo "Found release with $ASSET_COUNT assets (stable)" echo "Found release with $ASSET_COUNT assets"
break break
fi fi
echo "Release has $ASSET_COUNT assets (was $PREV_COUNT)... attempt $i/40" echo "Release exists but no assets yet... attempt $i/20"
PREV_COUNT="$ASSET_COUNT"
else else
echo "Waiting for Gitea release... attempt $i/40" echo "Waiting for Gitea release... attempt $i/20"
fi fi
sleep 30 sleep 30
done done
if [ -z "$RESPONSE" ] || [ "$ASSET_COUNT" -eq 0 ]; then if [ -z "$RESPONSE" ] || [ "$ASSET_COUNT" -eq 0 ]; then
echo "::error::Gitea release for ${TAG} not found or has no assets after 20 minutes" echo "::error::Gitea release for ${TAG} not found or has no assets after 10 minutes"
exit 1 exit 1
fi fi
# select() so an empty-string body produces an empty file — `// empty` echo "$RESPONSE" | jq -r '.body // empty' > /tmp/release-notes.md
# treats "" as truthy and wrote a blank line, defeating this fallback.
echo "$RESPONSE" | jq -r '.body | select(. != null and . != "")' > /tmp/release-notes.md
if [ ! -s /tmp/release-notes.md ]; then if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md
@@ -71,11 +62,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
PRERELEASE=""
case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac
gh release create "$TAG" \ gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \ --repo "$GITHUB_REPOSITORY" \
--title "$TAG" \ --title "$TAG" \
--notes-file /tmp/release-notes.md \ --notes-file /tmp/release-notes.md \
$PRERELEASE \
/tmp/assets/* /tmp/assets/*
+2 -5
View File
@@ -8,7 +8,6 @@ release:
gitea: gitea:
owner: lerkolabs owner: lerkolabs
name: uptop name: uptop
prerelease: auto
builds: builds:
- main: ./cmd/uptop - main: ./cmd/uptop
@@ -59,7 +58,5 @@ nfpms:
dst: /usr/share/doc/uptop/LICENSE dst: /usr/share/doc/uptop/LICENSE
type: doc type: doc
# Changelog generation must stay enabled: the --release-notes flag is consumed changelog:
# by the changelog pipe, so disabling it silently drops the git-cliff notes disable: true
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
# skips its own generation and uses the file.
+3 -8
View File
@@ -1,11 +1,6 @@
ignore: ignore:
# SCP path traversal in charmbracelet/wish — same flaw, two ids: grype has # CVE-2026-41589: SCP path traversal in charmbracelet/wish.
# matched it as CVE-2026-41589 and as GHSA-xjvp-7243-rg9h depending on db
# version, and ignore matching is exact-id, so both stay listed.
# We only import wish/bubbletea for the SSH TUI server — the vulnerable # We only import wish/bubbletea for the SSH TUI server — the vulnerable
# scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in # scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in.
# (govulncheck reachability agrees). No fix for wish v1; v2 # No fix available for wish v1; v2 (charm.land/wish/v2) patched in 2.0.1.
# (charm.land/wish/v2 >= 2.0.1) requires the bubbletea-v2 stack migration,
# tracked in issue #126. Remove both entries when that lands.
- vulnerability: CVE-2026-41589 - vulnerability: CVE-2026-41589
- vulnerability: GHSA-xjvp-7243-rg9h
+121 -175
View File
@@ -1,183 +1,129 @@
# Changelog # Changelog
## [Unreleased] ## [2026.06.2] — 2026-06-02 (infrastructure)
### Added
- initial commit — uptime monitor (forked from go-upkeep)
- enhanced dashboard with lipgloss tables, huh forms, mouse support, and animations
- upgrade users tab with lipgloss table, edit support, role select
- upgrade alerts tab with lipgloss table, click zones, colored types
- widen Site struct and DB schema for ping, port, dns, group monitor types
- add ping, port, and DNS check routines
- add ntfy notification provider with TUI support
- add Uptime Kuma backup converter with CLI and API
- add mouse wheel scrolling for all tabs
- add per-site pause, fix viewport, polish status page
- add monitor groups with collapse/expand and tree view
- add Telegram, PagerDuty, Pushover, Gotify providers
- add Prometheus /metrics endpoint
- expose HTTP method and accepted status codes in monitor form
- add config-as-code YAML import/export
- add distributed probing foundation — schema, models, and probe APIs
- add probe execution mode, check extraction, and result aggregation
- add region affinity, Nodes TUI tab, and probe metrics
- add status bar, tab badges, and detail panel
- bordered modals, welcome state, and dynamic name width
- DOWN-first sort, health pulse, and site filter
- split available width evenly between NAME and HISTORY columns
- add type icons to sites table
- persist logs to DB, load on startup
- add incident management and maintenance windows
- zebra striping, detail breadcrumb, sparkline stats, collapse persistence
- add --version flag with build metadata injection
- add theme system with 4 curated palettes
- swap light theme for Tokyo Night and Gruvbox
- seed SSH users from env var and authorized_keys file (#31)
- show error reason when monitors go DOWN
- proper push monitor lifecycle — PENDING, LATE, DOWN states
- logs tab overhaul — severity tags, filtering, recovery durations
- alert channel health indicator + test alerts
- add GitHub release relay workflow
- classify error reasons on DOWN monitors
- add state change history view with outage duration
- add Opsgenie provider
- add STALE state for push monitors
- add SLA reporting view
- overhaul latency sparkline scaling, color, and layout
- auto-prune expired maintenance windows
- click-to-inspect sparkline tooltips in detail view
### Changed ### Changed
- Split release pipeline into separate binary and Docker workflows (#45)
- replace database ID column with row counter - Pin Docker base images by digest (#45)
- unify SQLite and Postgres into dialect-based SQLStore - Add GitHub release relay — mirrors Gitea releases to GitHub (#49)
- add error returns to all Store interface methods - Add Grype CVE scanning to Docker pipeline (#45)
- remove store global singleton, thread store explicitly - Make CVE scan non-blocking for non-exploitable wish SCP vulnerability (#48)
- extract shared HTTPProvider for webhook-based alerts
- extract shared table rendering, fix cursor bounds
- encapsulate engine state, add graceful shutdown and tests
- split release pipeline, add nfpm/homebrew/git-cliff
- decompose god files into single-concern modules
- consistent chrome across all views
- status icons, clean STATUS column, relative time
- extract magic numbers into named constants
- check all discarded errors in sqlstore_test.go
- overhaul tab bar — consistent counts, active highlight, colored alerts
- responsive column hiding — 3-tier priority-based layout
- swap mattn/go-sqlite3 for modernc.org/sqlite
- propagate context.Context through all Store methods
- typed Status constants with IsBroken() predicate
- schema_version migration table + DeleteAlert FK fix
- shared storetest.BaseMock replaces 5 duplicated mocks
- consolidate env parsing into appConfig struct
- extract Server type with named handler methods
- split Site into SiteConfig + SiteState
- unify logging with log/slog
- restructure site form to 2 type-aware pages
### Fixed ### Fixed
- git-cliff install in CI — resolve download URL dynamically, extract to /tmp (#46, #47)
- forward all msg types to huh forms, improve row selection UX ## [2026.06.1] — 2026-06-01
- harden TLS, timeouts, validation, logging, and token generation
- add delete confirm, input validation, XSS fix, history persistence
- correctness and robustness fixes across all subsystems
- make status bar and tab badges visible
- use stable sort to prevent site list shuffling each tick
- sort children by ID before status to prevent map-order shuffling
- sparkline now spans full column width
- sparkline right-aligned — current time at right edge, dots fill left
- increase history buffer to 60 so sparkline fills completely
- compute uptime from windowed statuses, not running counters
- seed status and latency from DB history on startup
- strip push tokens from /status/json response
- correct viewport sizing and dynamic chrome calculation
- constrain form height to terminal and forward resize events
- skip children in maintenance when computing group status
- exclude maintenance'd monitors from down count and pulse
- group selection highlight, layout constants, group history graphs
- stable monitor count and universal group icons
- replace panic with error return, handle unmarshal errors
- add context to Provider.Send, log alert failures
- constant-time secret comparison, request size limits
- graceful shutdown for HTTP, SSH servers and database
- add jitter to check intervals and stagger startup
- use sh instead of bash for runner compatibility
- enable CGO for race detector, use lint-action v7
- install gcc for race detector support
- skip irrelevant field validation by monitor type
- guard max retries validator for group type
- tighten zebra row contrast for Tokyo Night and Gruvbox
- phase 1 critical fixes for public release
- phase 2 high-severity hardening
- phase 3 medium reliability and hardening
- phase 4 code quality and low-severity fixes
- rename GITEA_TOKEN to RELEASE_TOKEN
- remove explicit container, use sh shell
- bump golang.org/x/crypto v0.47.0 → v0.52.0
- install git and gcc for GoReleaser in release pipeline
- use internal Gitea URL for GoReleaser API calls
- use docker-builder runner for Docker image builds
- patch Docker Scout CVEs and remove unused openssh-client (#41)
- non-root user, supply chain attestations, build cleanup
- move SSH host key path into /data for non-root user
- create .ssh dir explicitly, ensure entrypoint is executable
- resolve git-cliff download URL dynamically
- extract git-cliff to /tmp to avoid dirty worktree
- make Grype CVE scan non-blocking for known wish vuln
- bump Go 1.26.3 → 1.26.4
- remove error truncation from detail panel
- classify safedial "failed to connect" as TCP
- resolve staticcheck lint errors in history view
- trigger immediate recheck after site config edit
- broken tick chain after form/dialog + retries off-by-one
- wire up [e] edit key in detail panel
- show push token and URL in detail panel
- show correct push heartbeat curl command in detail panel
- propagate STALE/LATE child status to group
- quick wins batch — version footer, column widths, zebra, sparkline
- logs tab use viewport for scrollable content
- pin footer to bottom of terminal
- normalize content whitespace for consistent footer position
- clip overflowing content to keep footer pinned
- remove extra blank lines above footer
- expand log viewport to fill content area
- log STALE recovery in push heartbeat handler
- check fmt.Sscanf return value (errcheck lint)
- inject time into ComputeDailyBreakdown for testability
- cascade delete related rows when removing a site
- merge check results into live state, never overwrite
- serialize DB writes through a single drained writer
- close XFF bypass and three secret-leak paths
- move blocking DB IO out of Update/View into tea.Cmds
- move theme styles onto the Model to end cross-session races
- finish moving keypress DB reads into tea.Cmds
- move all store writes out of Update into tea.Cmds
- mask alert secrets in the TUI detail panel and table
- serve /status/json through a public DTO
- make SSH key revocation fail closed
- six correctness fixes for the state machine
- migrate Postgres timestamps to TIMESTAMPTZ
- seven quick-win bug fixes across engine, server, TUI, CLI
- SSRF guard gaps + DNS port restriction + metrics auth
- track selection by site ID + q means back everywhere
- apply convergence + push/group check history
- Kuma import tokens/paused, Docker hardening, migrate-secrets idempotency
- six small fixes — rate limiter leak, DST SLA, probe sort, TUI cleanup
- seven fixes — token scan, variadic cleanup, TUI layout, compose secrets
- chmod SQLite DB files to 0600 on open
- close DNS-rebind TOCTOU on ping/port checks
- API import no longer replaces user accounts
- email send respects context deadline
- rename X-Upkeep-Secret header to X-Uptop-Secret
- apply log filter to full log list, not viewport window
- repair pipeline defects found in v0.1.0-rc.1 rehearsal
- suppress wish GHSA alias in grype, fold rc tags into launch notes
- scan gates docker push, rc tags spare :latest, mirror waits for stable assets
- remove tagged scan image in cleanup step
- exclude rc tags from cliff tag_pattern so launch notes span full history
- fall back to embedded build info when ldflags absent
- drop body-grep Security grouping, map polish type in cliff
- sync selectedID on click so refreshLive doesn't revert cursor
- resolve 4 tag-blocking issues for v0.1.0
### Changed
- Container runs as non-root user `uptop` (UID/GID 1000) instead of root (#44)
- SSH host key relocated to `/data/.ssh/id_ed25519` for non-root compatibility (#44)
- Release workflow prunes dangling images and build cache after Docker push (#44)
### Added
- SBOM and provenance attestations on Docker images for supply chain compliance (#44)
- Entrypoint script with volume writability check and migration guidance (#44)
### Breaking
- Existing Docker volumes with root-owned files require migration before upgrading:
`docker run --rm -v <volume>:/data alpine chown -R 1000:1000 /data`
## [2026.05.6] — 2026-05-30 (infrastructure)
### Changed
- Sync README to Docker Hub on release (#43)
### Security
- Patch Docker Scout CVEs, remove unused openssh-client (#41)
## [2026.05.5] — 2026-05-29
### Added
- Error reason display when monitors go DOWN (#33)
- Push monitor lifecycle — PENDING, LATE, DOWN states (#34)
- Logs tab overhaul — severity tags, filtering, recovery durations (#35)
- Alert channel health indicator and test alerts (#36)
- TUI screenshots in `assets/` (#32)
- CI status badge in README
### Changed
- Visual polish — detail sections, column headers, alert detail (#37)
- README rewritten with hero image, badges, collapsible install sections (#32)
- Changelog rewritten to match actual CalVer tag history
- Migrated to `lerkolabs` org namespace (#38)
- Docker-compose files moved to `deploy/`
## [2026.05.4] — 2026-05-27
### Added
- SSH user seeding from `UPTOP_ADMIN_KEY` env var and `UPTOP_KEYS` file (#31)
- GoReleaser for binary releases
- govulncheck in CI pipeline
- Multi-arch Docker builds (amd64 + arm64)
### Changed
- CI overhaul — Go 1.26, build caching, streamlined pipeline (#30)
- Bumped golang.org/x/crypto v0.47.0 → v0.52.0
- Bumped Alpine 3.21 → 3.23
### Security
- Phase 1: SSRF protection, input validation, safe dial (#26)
- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27)
- Phase 3: Graceful degradation, connection limits, timeout enforcement (#28)
- Phase 4: Code quality, error handling, linter fixes (#29)
## [2026.05.3] — 2026-05-25
### Added
- Theme system with 5 dark palettes — Default, Dracula, Nord, Tokyo Night, Gruvbox (#24)
- `--version` flag with build metadata injection
- Gitea Actions CI pipeline — test + lint (#20)
- golangci-lint configuration
- Comprehensive test suite — 94 tests across monitor, server, cluster (#19)
- CONTRIBUTING.md and SECURITY.md
### Changed
- Renamed project from go-upkeep to uptop (#25)
- Updated LICENSE with dual copyright for independent fork
### Fixed
- Form validators scoped to relevant monitor types (#23)
- Graceful shutdown for HTTP, SSH servers and database (#19)
- Constant-time secret comparison, request size limits (#19)
- Check interval jitter to prevent thundering herd (#19)
- TUI visual polish — zebra striping, group icons, sparkline stats (#18)
## [2026.05.2] — 2026-05-22
### Added
- Incident management and maintenance windows (#17)
- Production docker-compose.yml
### Fixed
- Viewport sizing and dynamic chrome calculation (#16)
- Form height constrained to terminal with resize forwarding
- Maintenance'd monitors excluded from down count and pulse
- Group status correctly skips children in maintenance
## [2026.05.1] — 2026-05-16
### Added
- Distributed probing with leader + probe nodes
- Config-as-code — YAML apply/export with dry-run and prune
- TUI polish — status bar, tab badges, detail panel, modals
- DOWN-first sort, health pulse, site filter
- Type icons in sites table
- Sparkline history graphs
- Persistent state — uptime, status, latency, and logs survive restarts
- Push token stripping from /status/json response
## [2026.04.1] — 2026-04-01
### Added
- SSH-accessible TUI built on Bubble Tea + Wish
- 6 check types — HTTP, Push, Ping, Port, DNS, Group
- 9 alert providers — Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook
- SQLite and PostgreSQL support
- HA clustering with automatic failover
- Prometheus /metrics endpoint
- Public status page (HTML + JSON)
- Uptime Kuma backup import
+1 -1
View File
@@ -3,7 +3,7 @@
## Development ## Development
```sh ```sh
go run ./cmd/uptop -demo # starts with sample data go run cmd/uptop/main.go -demo # starts with sample data
ssh -p 23234 localhost # connect to TUI ssh -p 23234 localhost # connect to TUI
``` ```
+1 -1
View File
@@ -1,5 +1,5 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder FROM golang:1.26-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
+6 -27
View File
@@ -19,8 +19,6 @@ An uptime monitor you manage entirely from the terminal. It runs as a server, ex
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard. Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard.
Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.com/lerkolabs/uptop) — [GitHub](https://github.com/lerkolabs/uptop) is a mirror; releases are published to both.
## Features ## Features
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups - **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
@@ -32,8 +30,6 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
- **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
> Group monitors roll up child status for display but don't fire their own alerts yet — attach alerts to the children.
## Screenshots ## Screenshots
<table> <table>
@@ -53,14 +49,14 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
## Quick start ## Quick start
```bash ```bash
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop go run cmd/uptop/main.go
ssh -p 23234 localhost ssh -p 23234 localhost
``` ```
Want some data to look at first: Want some data to look at first:
```bash ```bash
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop -demo go run cmd/uptop/main.go -demo
``` ```
## Install ## Install
@@ -83,20 +79,16 @@ services:
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host # - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
volumes: volumes:
- ./data:/data - ./data:/data
sysctls:
- net.ipv4.ping_group_range=0 2147483647
``` ```
First run: set `UPTOP_ADMIN_KEY` to your SSH public key. First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab.
The `sysctls` line enables unprivileged ICMP inside the container — without it, ping monitors get no response and silently report DOWN.
</details> </details>
<details> <details>
<summary><strong>Binary (Linux, macOS, Windows)</strong></summary> <summary><strong>Binary (Linux amd64)</strong></summary>
Download from [Releases](https://github.com/lerkolabs/uptop/releases) — amd64 and arm64 tarballs (zip for Windows), plus `.deb`/`.rpm` packages and `checksums.txt`. Download from [Releases](https://github.com/lerkolabs/uptop/releases).
</details> </details>
@@ -170,19 +162,6 @@ Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook
Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set. Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set.
### Data retention
uptop prunes its own history in the background — no external cleanup jobs needed:
| Data | Kept |
|---|---|
| Check history | newest 1,000 checks per monitor |
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
| Logs | newest 200 entries |
| Maintenance windows | 7 days after they end (configurable) |
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.
## Clustering ## Clustering
uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions). uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions).
@@ -195,7 +174,7 @@ Export your Kuma backup JSON, then:
```bash ```bash
curl -X POST http://localhost:8080/api/import/kuma \ curl -X POST http://localhost:8080/api/import/kuma \
-H "X-Uptop-Secret: your-secret" \ -H "X-Upkeep-Secret: your-secret" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @kuma-backup.json -d @kuma-backup.json
``` ```
+2 -8
View File
@@ -23,13 +23,7 @@ filter_unconventional = true
split_commits = false split_commits = false
protect_breaking_commits = false protect_breaking_commits = false
filter_commits = false filter_commits = false
# Only final tags count as releases — rc rehearsal tags must not become tag_pattern = "v[0-9].*"
# section boundaries, or the final tag's notes would cover only
# commits-since-last-rc (v0.1.0 rendered 0 commits with ignore_tags, which
# drops rc-tagged commits instead of folding them forward). With rc tags
# outside the pattern, finals render the full span and rc tags render
# [Unreleased] with everything pending. Verified empirically on both.
tag_pattern = 'v[0-9]+\.[0-9]+\.[0-9]+$'
topo_order = false topo_order = false
sort_commits = "oldest" sort_commits = "oldest"
@@ -39,7 +33,7 @@ commit_parsers = [
{ message = "^perf", group = "Changed" }, { message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" }, { message = "^refactor", group = "Changed" },
{ message = "^security", group = "Security" }, { message = "^security", group = "Security" },
{ message = "^polish", group = "Changed" }, { body = ".*security", group = "Security" },
{ body = "BREAKING", group = "Breaking" }, { body = "BREAKING", group = "Breaking" },
{ footer = "BREAKING.CHANGE", group = "Breaking" }, { footer = "BREAKING.CHANGE", group = "Breaking" },
{ message = "^docs", skip = true }, { message = "^docs", skip = true },
+4 -36
View File
@@ -12,7 +12,6 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime/debug"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -40,30 +39,6 @@ var (
date = "unknown" date = "unknown"
) )
// GoReleaser stamps the vars above via ldflags, but `go install module@tag`
// compiles without them and would report "dev". The module version and any
// vcs stamps are embedded in every binary, so fall back to those.
func init() {
if version != "dev" {
return
}
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
if mv := info.Main.Version; mv != "" && mv != "(devel)" {
version = strings.TrimPrefix(mv, "v")
}
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
commit = s.Value
case "vcs.time":
date = s.Value
}
}
}
func main() { func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo, Level: slog.LevelInfo,
@@ -89,18 +64,11 @@ func main() {
} }
func printVersion() { func printVersion() {
out := "uptop " + version if version == "dev" {
var meta []string fmt.Println("uptop dev")
if commit != "none" { } else {
meta = append(meta, commit) fmt.Printf("uptop %s (%s, %s)\n", version, commit, date)
} }
if date != "unknown" {
meta = append(meta, date)
}
if len(meta) > 0 {
out += " (" + strings.Join(meta, ", ") + ")"
}
fmt.Println(out)
} }
func envOrDefault(key, fallback string) string { func envOrDefault(key, fallback string) string {
+2 -2
View File
@@ -3,7 +3,7 @@ services:
# LEADER NODE # LEADER NODE
# ------------------------- # -------------------------
leader: leader:
image: lerkolabs/uptop:latest build: .
container_name: uptop-leader container_name: uptop-leader
ports: ports:
- "23234:23234" # SSH - "23234:23234" # SSH
@@ -38,7 +38,7 @@ services:
# FOLLOWER NODE # FOLLOWER NODE
# ------------------------- # -------------------------
follower: follower:
image: lerkolabs/uptop:latest build: .
container_name: uptop-follower container_name: uptop-follower
ports: ports:
- "23233:23234" # SSH (Mapped to different host port) - "23233:23234" # SSH (Mapped to different host port)
+1 -1
View File
@@ -2,7 +2,7 @@ services:
# The Application # The Application
app: app:
build: build:
context: .. context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: uptop-dev container_name: uptop-dev
ports: ports:
+3 -3
View File
@@ -1,6 +1,6 @@
services: services:
leader: leader:
image: lerkolabs/uptop:latest build: .
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
@@ -11,7 +11,7 @@ services:
- "23234:23234" - "23234:23234"
probe-us-east: probe-us-east:
image: lerkolabs/uptop:latest build: .
environment: environment:
- UPTOP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=us-east-1 - UPTOP_NODE_ID=us-east-1
@@ -23,7 +23,7 @@ services:
- leader - leader
probe-eu-west: probe-eu-west:
image: lerkolabs/uptop:latest build: .
environment: environment:
- UPTOP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=eu-west-1 - UPTOP_NODE_ID=eu-west-1
+3 -1
View File
@@ -1,6 +1,8 @@
services: services:
app: app:
image: lerkolabs/uptop:latest build:
context: .
dockerfile: Dockerfile
container_name: uptop container_name: uptop
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
+1 -1
View File
@@ -81,5 +81,5 @@ 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 are unauthenticated.
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production. - Secrets are sent in HTTP headers (`X-Upkeep-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.
+2 -31
View File
@@ -122,7 +122,7 @@ Groups can't nest inside other groups. A group is healthy when all its children
## Alert types ## Alert types
All 10 providers work in the YAML. The `settings` map is different per type. All 9 providers work in the YAML. The `settings` map is different per type.
```yaml ```yaml
# Discord / Slack / Generic Webhook — just a URL # Discord / Slack / Generic Webhook — just a URL
@@ -149,9 +149,6 @@ All 10 providers work in the YAML. The `settings` map is different per type.
url: https://ntfy.sh url: https://ntfy.sh
topic: my-alerts topic: my-alerts
priority: "4" priority: "4"
# for protected topics:
# username: user
# password: pass
# Telegram # Telegram
- name: Telegram Ops - name: Telegram Ops
@@ -181,14 +178,6 @@ All 10 providers work in the YAML. The `settings` map is different per type.
url: https://gotify.example.com url: https://gotify.example.com
token: app-token token: app-token
priority: "8" priority: "8"
# Opsgenie
- name: Opsgenie
type: opsgenie
settings:
api_key: your-api-key
priority: P2 # P1P5, default P3
# eu: "true" # use the EU API endpoint
``` ```
## Commands ## Commands
@@ -235,25 +224,7 @@ Monitors and alerts are matched by **name**. Names must be unique across the ent
Apply is idempotent. Run it twice with the same file, second run changes nothing. Apply is idempotent. Run it twice with the same file, second run changes nothing.
Apply is **not atomic** — items are written one at a time, so an error mid-apply (bad value, lost DB connection, ctrl-C) leaves the items already written in place. That's safe to recover from: apply diffs against the database by name, so fix the issue and run it again — it converges the rest. Just don't run two applies against the same database at once. If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
## Backups and secrets
`uptop export` writes alert credentials (SMTP passwords, API tokens, webhook URLs) into the YAML in clear text — that's what makes the file restorable. Treat it like a secrets file.
The HTTP export endpoint redacts those same fields **by default**:
```bash
# secrets show as ***REDACTED*** — fine for sharing or review
curl -H "X-Uptop-Secret: your-secret" \
"http://localhost:8080/api/backup/export"
# full backup you can actually restore from
curl -H "X-Uptop-Secret: your-secret" \
"http://localhost:8080/api/backup/export?redact_secrets=false"
```
Restoring a redacted export imports the literal string `***REDACTED***` as your credentials. For real backups, pass `redact_secrets=false` or run `uptop export` on the host.
## Typical workflow ## Typical workflow
+1 -1
View File
@@ -52,7 +52,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil) req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
if cfg.SharedKey != "" { if cfg.SharedKey != "" {
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
+1 -1
View File
@@ -113,7 +113,7 @@ func TestFollowerLoop_SendsSecret(t *testing.T) {
var receivedSecret string var receivedSecret string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock() mu.Lock()
receivedSecret = r.Header.Get("X-Uptop-Secret") receivedSecret = r.Header.Get("X-Upkeep-Secret")
mu.Unlock() mu.Unlock()
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte("OK")) w.Write([]byte("OK"))
+3 -3
View File
@@ -90,7 +90,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
@@ -108,7 +108,7 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -180,7 +180,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
-7
View File
@@ -54,7 +54,6 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
alertMap[ea.Name] = ea.ID alertMap[ea.Name] = ea.ID
} }
nextPlaceholderID := -1
desiredAlertNames := make(map[string]bool, len(f.Alerts)) desiredAlertNames := make(map[string]bool, len(f.Alerts))
for _, a := range f.Alerts { for _, a := range f.Alerts {
desiredAlertNames[a.Name] = true desiredAlertNames[a.Name] = true
@@ -67,9 +66,6 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
return changes, fmt.Errorf("create alert %q: %w", a.Name, err) return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
} }
alertMap[a.Name] = id alertMap[a.Name] = id
} else {
alertMap[a.Name] = nextPlaceholderID
nextPlaceholderID--
} }
} else { } else {
alertMap[a.Name] = existing.ID alertMap[a.Name] = existing.ID
@@ -113,9 +109,6 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
return changes, fmt.Errorf("create group %q: %w", g.Name, err) return changes, fmt.Errorf("create group %q: %w", g.Name, err)
} }
groupMap[g.Name] = id groupMap[g.Name] = id
} else {
groupMap[g.Name] = nextPlaceholderID
nextPlaceholderID--
} }
} else { } else {
groupMap[g.Name] = existing.ID groupMap[g.Name] = existing.ID
-68
View File
@@ -266,74 +266,6 @@ func TestApplyDuplicateNames(t *testing.T) {
} }
} }
func TestApplyDryRunNewAlertAndMonitor(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
},
}
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("dry-run with new alert+monitor should not error: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 2 {
t.Fatalf("expected 2 creates (alert+monitor), got %d: %+v", creates, changes)
}
sites, _ := s.GetSites(context.Background())
alerts, _ := s.GetAllAlerts(context.Background())
if len(sites) != 0 {
t.Fatalf("dry-run should not persist sites, got %d", len(sites))
}
if len(alerts) != 0 {
t.Fatalf("dry-run should not persist alerts, got %d", len(alerts))
}
}
func TestApplyDryRunNewGroupWithChildren(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Slack", Type: "slack", Settings: map[string]string{"url": "https://hooks.example.com"}},
},
Monitors: []Monitor{
{
Name: "Prod", Type: "group", Alert: "Slack",
Monitors: []Monitor{
{Name: "API", Type: "http", URL: "https://api.example.com", Interval: 15, Alert: "Slack"},
},
},
},
}
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("dry-run with new group+alert should not error: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 3 {
t.Fatalf("expected 3 creates (alert+group+child), got %d: %+v", creates, changes)
}
}
func TestApplyExistingAlertReference(t *testing.T) { func TestApplyExistingAlertReference(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"}) s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"})
+2 -2
View File
@@ -127,7 +127,7 @@ func (s *Server) routes() http.Handler {
} }
func (s *Server) requireAuth(r *http.Request) bool { func (s *Server) requireAuth(r *http.Request) bool {
return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey)
} }
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
@@ -159,7 +159,7 @@ 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) { if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
+1 -1
View File
@@ -141,7 +141,7 @@ func authReq(method, url, secret string, body []byte) (*http.Response, error) {
return nil, err return nil, err
} }
if secret != "" { if secret != "" {
req.Header.Set("X-Uptop-Secret", secret) req.Header.Set("X-Upkeep-Secret", secret)
} }
return http.DefaultClient.Do(req) return http.DefaultClient.Do(req)
} }
+100 -125
View File
@@ -326,50 +326,27 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
} }
} }
return m.rebuildSiteForm() // m.alerts is the tab-data cache (≤5s stale) — no store IO in Update.
} alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
func (m *Model) rebuildSiteForm() tea.Cmd {
groups := m.buildSiteFormGroups()
m.huhForm = huh.NewForm(groups...).WithTheme(m.theme.HuhTheme())
if m.termWidth > 0 {
m.huhForm.WithWidth(m.termWidth)
}
formHeight := m.termHeight - 7
if formHeight < 5 {
formHeight = 5
}
m.huhForm.WithHeight(formHeight)
m.lastSiteType = m.siteFormData.SiteType
return m.huhForm.Init()
}
func (m *Model) siteFormOptions() (alertOpts, groupOpts []huh.Option[string]) {
alertOpts = []huh.Option[string]{huh.NewOption("None", "0")}
for _, a := range m.alerts { for _, a := range m.alerts {
alertOpts = append(alertOpts, huh.NewOption( alertOpts = append(alertOpts, huh.NewOption(
fmt.Sprintf("%s (%s)", a.Name, a.Type), fmt.Sprintf("%s (%s)", a.Name, a.Type),
strconv.Itoa(a.ID), strconv.Itoa(a.ID),
)) ))
} }
groupOpts = []huh.Option[string]{huh.NewOption("None", "0")}
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites { for _, s := range m.sites {
if s.Type == "group" && s.ID != m.editID { if s.Type == "group" && s.ID != m.editID {
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID))) groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
} }
} }
return
}
func (m *Model) buildSiteFormGroups() []*huh.Group { m.huhForm = huh.NewForm(
d := m.siteFormData huh.NewGroup(
alertOpts, groupOpts := m.siteFormOptions()
// Page 1 — Monitor Setup: core fields + type-specific target
setup := []huh.Field{
huh.NewInput().Title("Monitor Name"). huh.NewInput().Title("Monitor Name").
Placeholder("My Service"). Placeholder("My Service").
Value(&d.Name). Value(&m.siteFormData.Name).
Validate(func(s string) error { Validate(func(s string) error {
if s == "" { if s == "" {
return fmt.Errorf("name is required") return fmt.Errorf("name is required")
@@ -384,20 +361,22 @@ func (m *Model) buildSiteFormGroups() []*huh.Group {
huh.NewOption("TCP Port", "port"), huh.NewOption("TCP Port", "port"),
huh.NewOption("DNS", "dns"), huh.NewOption("DNS", "dns"),
huh.NewOption("Group", "group"), huh.NewOption("Group", "group"),
).Value(&d.SiteType), ).Value(&m.siteFormData.SiteType),
huh.NewSelect[string]().Title("Alert Channel"). huh.NewSelect[string]().Title("Alert Channel").
Options(alertOpts...). Options(alertOpts...).
Value(&d.AlertID), Value(&m.siteFormData.AlertID),
} ).Title("Monitor Settings"),
huh.NewGroup(
switch d.SiteType { huh.NewInput().Title("URL").
case "http":
setup = append(setup, huh.NewInput().Title("URL").
Placeholder("https://example.com"). Placeholder("https://example.com").
Value(&d.URL). Description("Required for HTTP monitors").
Value(&m.siteFormData.URL).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType != "http" {
return nil
}
if s == "" { if s == "" {
return fmt.Errorf("URL is required") return fmt.Errorf("URL is required for HTTP monitors")
} }
u, err := url.Parse(s) u, err := url.Parse(s)
if err != nil { if err != nil {
@@ -410,64 +389,14 @@ func (m *Model) buildSiteFormGroups() []*huh.Group {
return fmt.Errorf("URL must include a host") return fmt.Errorf("URL must include a host")
} }
return nil return nil
})) }),
case "ping", "dns":
setup = append(setup, huh.NewInput().Title("Hostname / IP").
Placeholder("10.0.0.1").
Value(&d.Hostname))
case "port":
setup = append(setup,
huh.NewInput().Title("Hostname / IP").
Placeholder("10.0.0.1").
Value(&d.Hostname),
huh.NewInput().Title("Port").
Placeholder("443").
Value(&d.Port).
Validate(func(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 || v > 65535 {
return fmt.Errorf("port must be 1-65535")
}
return nil
}))
}
groups := []*huh.Group{huh.NewGroup(setup...).Title("Monitor Setup")}
if d.SiteType == "group" {
return groups
}
// Page 2 — Configuration: type-specific options + shared defaults
var config []huh.Field
if d.SiteType == "http" {
config = append(config,
huh.NewSelect[string]().Title("HTTP Method").
Options(
huh.NewOption("GET", "GET"),
huh.NewOption("POST", "POST"),
huh.NewOption("PUT", "PUT"),
huh.NewOption("PATCH", "PATCH"),
huh.NewOption("DELETE", "DELETE"),
huh.NewOption("HEAD", "HEAD"),
huh.NewOption("OPTIONS", "OPTIONS"),
).Value(&d.Method),
huh.NewInput().Title("Accepted Status Codes").
Placeholder("200-299").
Description("Ranges (200-299) and singles (301) separated by commas").
Value(&d.AcceptedCodes),
)
}
config = append(config,
huh.NewInput().Title("Check Interval (seconds)"). huh.NewInput().Title("Check Interval (seconds)").
Placeholder("60"). Placeholder("60").
Value(&d.Interval). Value(&m.siteFormData.Interval).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
if err != nil { if err != nil {
return fmt.Errorf("must be a number") return fmt.Errorf("must be a number")
@@ -477,10 +406,37 @@ func (m *Model) buildSiteFormGroups() []*huh.Group {
} }
return nil return nil
}), }),
huh.NewSelect[string]().Title("Parent Group").
Options(groupOpts...).
Value(&m.siteFormData.GroupID),
huh.NewInput().Title("Hostname / IP").
Placeholder("10.0.0.1").
Description("Target for ping/port/DNS monitors").
Value(&m.siteFormData.Hostname),
huh.NewInput().Title("Port").
Placeholder("0").
Description("Target port for TCP port monitors").
Value(&m.siteFormData.Port).
Validate(func(s string) error {
if m.siteFormData.SiteType != "port" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 || v > 65535 {
return fmt.Errorf("port must be 1-65535")
}
return nil
}),
huh.NewInput().Title("Timeout (seconds)"). huh.NewInput().Title("Timeout (seconds)").
Placeholder("5"). Placeholder("5").
Value(&d.Timeout). Value(&m.siteFormData.Timeout).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
if err != nil { if err != nil {
return fmt.Errorf("must be a number") return fmt.Errorf("must be a number")
@@ -490,40 +446,42 @@ func (m *Model) buildSiteFormGroups() []*huh.Group {
} }
return nil return nil
}), }),
huh.NewInput().Title("Max Retries Before Alert").
Placeholder("0").
Value(&d.Retries).
Validate(func(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 0 {
return fmt.Errorf("retries cannot be negative")
}
return nil
}),
huh.NewSelect[string]().Title("Parent Group").
Options(groupOpts...).
Value(&d.GroupID),
huh.NewInput().Title("Description"). huh.NewInput().Title("Description").
Placeholder("Optional description"). Placeholder("Optional description").
Value(&d.Description), Value(&m.siteFormData.Description),
huh.NewInput().Title("Probe Regions"). huh.NewInput().Title("Probe Regions").
Placeholder("us-east, eu-west (empty = all)"). Placeholder("us-east, eu-west (empty = all)").
Description("Comma-separated regions for distributed probing"). Description("Comma-separated regions for distributed probing").
Value(&d.Regions), Value(&m.siteFormData.Regions),
) ).Title("Connection").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
if d.SiteType == "http" { }),
config = append(config, huh.NewGroup(
huh.NewSelect[string]().Title("HTTP Method").
Options(
huh.NewOption("GET", "GET"),
huh.NewOption("POST", "POST"),
huh.NewOption("PUT", "PUT"),
huh.NewOption("PATCH", "PATCH"),
huh.NewOption("DELETE", "DELETE"),
huh.NewOption("HEAD", "HEAD"),
huh.NewOption("OPTIONS", "OPTIONS"),
).Value(&m.siteFormData.Method),
huh.NewInput().Title("Accepted Status Codes").
Placeholder("200-299").
Description("Ranges (200-299) and singles (301) separated by commas").
Value(&m.siteFormData.AcceptedCodes),
).Title("HTTP Settings").WithHideFunc(func() bool {
return m.siteFormData.SiteType != "http"
}),
huh.NewGroup(
huh.NewConfirm().Title("Monitor SSL Certificate?"). huh.NewConfirm().Title("Monitor SSL Certificate?").
Value(&d.CheckSSL), Value(&m.siteFormData.CheckSSL),
huh.NewInput().Title("SSL Warning Threshold (days)"). huh.NewInput().Title("SSL Warning Threshold (days)").
Placeholder("7"). Placeholder("7").
Value(&d.Threshold). Value(&m.siteFormData.Threshold).
Validate(func(s string) error { Validate(func(s string) error {
if !d.CheckSSL { if !m.siteFormData.CheckSSL {
return nil return nil
} }
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
@@ -535,13 +493,30 @@ func (m *Model) buildSiteFormGroups() []*huh.Group {
} }
return nil return nil
}), }),
huh.NewConfirm().Title("Ignore TLS Errors?"). huh.NewInput().Title("Max Retries Before Alert").
Value(&d.IgnoreTLS), Placeholder("0").
) Value(&m.siteFormData.Retries).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
} }
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 0 {
return fmt.Errorf("retries cannot be negative")
}
return nil
}),
huh.NewConfirm().Title("Ignore TLS Errors?").
Value(&m.siteFormData.IgnoreTLS),
).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
).WithTheme(m.theme.HuhTheme())
groups = append(groups, huh.NewGroup(config...).Title("Configuration")) return m.huhForm.Init()
return groups
} }
func (m *Model) submitSiteForm() tea.Cmd { func (m *Model) submitSiteForm() tea.Cmd {
-1
View File
@@ -115,7 +115,6 @@ type Model struct {
huhForm *huh.Form huhForm *huh.Form
siteFormData *siteFormData siteFormData *siteFormData
lastSiteType string
alertFormData *alertFormData alertFormData *alertFormData
userFormData *userFormData userFormData *userFormData
maintFormData *maintFormData maintFormData *maintFormData
-8
View File
@@ -128,13 +128,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if f, ok := form.(*huh.Form); ok { if f, ok := form.(*huh.Form); ok {
m.huhForm = f m.huhForm = f
} }
if m.state == stateFormSite && m.siteFormData != nil &&
m.siteFormData.SiteType != m.lastSiteType {
rebuildCmd := m.rebuildSiteForm()
// Advance to Type select — user just changed it.
skipName := m.huhForm.NextField()
return m, tea.Batch(rebuildCmd, skipName)
}
if m.huhForm.State == huh.StateCompleted { if m.huhForm.State == huh.StateCompleted {
// The store write runs in the returned Cmd; its writeDoneMsg // The store write runs in the returned Cmd; its writeDoneMsg
// triggers the tab-data reload once the row actually exists. // triggers the tab-data reload once the row actually exists.
@@ -734,7 +727,6 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
for i := m.tableOffset; i < end; i++ { for i := m.tableOffset; i < end; i++ {
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()
return m, nil return m, nil
} }
} }