11 Commits

Author SHA1 Message Date
lerko dc4c5fdf8a fix(release): remove tagged scan image in cleanup step
CI / test (pull_request) Successful in 1m51s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 51s
Release Binaries / release (push) Successful in 2m18s
Release Docker / docker (push) Successful in 10m52s
2026-06-12 17:21:42 -04:00
lerko 96eb3e8185 fix(release): scan gates docker push, rc tags spare :latest, mirror waits for stable assets
CI / test (pull_request) Successful in 1m52s
CI / lint (pull_request) Successful in 1m16s
CI / vulncheck (pull_request) Successful in 50s
rc.2 proved the grype gate was decorative — buildx pushed before the
scan ran, so a red run still shipped the image (and rc tags moved
:latest). Build amd64 locally, scan that, then run the multi-arch push
from the warm builder cache. :latest now only moves on non-rc tags.

mirror-release: poll until the Gitea asset count is stable across two
polls (GoReleaser uploads sequentially — assets>0 could mirror a partial
set) and stretch the timeout to 20 min since the release run can queue
behind the Docker job on the single runner.
2026-06-12 17:20:48 -04:00
lerko 37bf443e29 fix(release): suppress wish GHSA alias in grype, fold rc tags into launch notes
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 51s
Release Binaries / release (push) Successful in 2m9s
Release Docker / docker (push) Successful in 10m18s
The existing .grype.yaml ignore listed the wish SCP traversal only by CVE
id; grype's db now matches it as GHSA-xjvp-7243-rg9h and ignores are
exact-id, so the rc.2 scan gate tripped on an already-triaged finding.
List both ids. Vulnerable SCP middleware is never compiled in; real fix
is the charm v2 stack migration (#126).

cliff.toml ignore_tags folds rc tags into the next real release so
v0.1.0's notes cover full history instead of commits-since-rc.2.
2026-06-12 17:02:55 -04:00
lerko f53dfa1c4c fix(release): repair pipeline defects found in v0.1.0-rc.1 rehearsal
CI / test (pull_request) Successful in 1m44s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 50s
Release Binaries / release (push) Successful in 2m11s
Release Docker / docker (push) Failing after 10m3s
Four defects from the rc.1 dress rehearsal:

- Dockerfile pinned golang:1.26-alpine3.23 at a 1.26.3 digest while
  go.mod requires 1.26.4; golang images set GOTOOLCHAIN=local, so the
  build hard-fails. Pin 1.26.4-alpine3.23 explicitly.
- changelog.disable swallowed --release-notes (the flag is consumed by
  the changelog pipe), publishing empty release bodies. Re-enable.
- Remove the Gitea-side GitHub relay step: redundant with
  .github/workflows/mirror-release.yml, which runs on GitHub Actions
  with the built-in token and copies the canonical Gitea assets.
- mirror-release.yml: jq '.body // empty' treats "" as truthy so the
  notes fallback never fired; use select(). Mark rc tags --prerelease.
2026-06-12 16:16:28 -04:00
lerko 4070691407 docs: close pre-release documentation gaps
CI / test (pull_request) Successful in 2m0s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 51s
Release Binaries / release (push) Failing after 8m31s
Release Docker / docker (push) Failing after 2m17s
- Docker compose: ping_group_range sysctl, without which ping monitors
  silently report DOWN in containers
- README: data retention table (1000 checks / 5000 state changes per
  monitor, 200 logs, pruned automatically), group-alert limitation note
- config-as-code: apply is not atomic + re-run convergence, backup
  redaction footgun (/api/backup/export redacts by default), opsgenie
  example (provider count was stale at 9), ntfy auth keys
2026-06-12 15:37:47 -04:00
lerko 6dfd56dcd4 ci(release): skip GitHub relay when GH_MIRROR_TOKEN absent
CI / test (pull_request) Successful in 1m55s
CI / lint (pull_request) Successful in 1m26s
CI / vulncheck (pull_request) Successful in 56s
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:31:57 -04:00
lerko 17b5557e23 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 15:31:57 -04:00
lerko dc27547ffb docs(monitor): document before-Start contract on engine setters 2026-06-12 15:31:57 -04:00
lerko 83ec6bee42 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 15:31:57 -04:00
lerko d538aad18e 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 15:31:57 -04:00
lerko ab0a69d06b fix(cluster)!: rename X-Upkeep-Secret header to X-Uptop-Secret
CI / test (pull_request) Successful in 1m57s
CI / lint (pull_request) Successful in 1m27s
CI / vulncheck (pull_request) Successful in 56s
Last upkeep-era name in the wire protocol. Breaking for mixed-version
clusters, but zero installed base exists pre-v0.1.0 — free now, breaking
forever after first tag.
2026-06-12 14:27:44 -04:00
21 changed files with 366 additions and 44 deletions
+4
View File
@@ -52,3 +52,7 @@ jobs:
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
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
# which runs on GitHub Actions when the push mirror delivers the tag and
# copies this run's Gitea release assets — no PAT needed on this side.
+27 -6
View File
@@ -35,8 +35,12 @@ 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
TAGS="${TAGS},lerkolabs/uptop:latest" case "$TAG" in
*-*) ;;
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;;
esac
fi fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT" echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
@@ -52,6 +56,26 @@ 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:
@@ -66,11 +90,6 @@ 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:
@@ -81,5 +100,7 @@ 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
+19 -7
View File
@@ -19,26 +19,35 @@ 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}"
for i in $(seq 1 20); do # 40 x 30s = 20 min: the Gitea release can queue behind the ~18-min
# 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 ]; then if [ "$ASSET_COUNT" -gt 0 ] && [ "$ASSET_COUNT" -eq "$PREV_COUNT" ]; then
echo "Found release with $ASSET_COUNT assets" echo "Found release with $ASSET_COUNT assets (stable)"
break break
fi fi
echo "Release exists but no assets yet... attempt $i/20" echo "Release has $ASSET_COUNT assets (was $PREV_COUNT)... attempt $i/40"
PREV_COUNT="$ASSET_COUNT"
else else
echo "Waiting for Gitea release... attempt $i/20" echo "Waiting for Gitea release... attempt $i/40"
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 10 minutes" echo "::error::Gitea release for ${TAG} not found or has no assets after 20 minutes"
exit 1 exit 1
fi fi
echo "$RESPONSE" | jq -r '.body // empty' > /tmp/release-notes.md # select() so an empty-string body produces an empty file — `// empty`
# 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
@@ -62,8 +71,11 @@ 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/*
+5 -2
View File
@@ -8,6 +8,7 @@ release:
gitea: gitea:
owner: lerkolabs owner: lerkolabs
name: uptop name: uptop
prerelease: auto
builds: builds:
- main: ./cmd/uptop - main: ./cmd/uptop
@@ -58,5 +59,7 @@ nfpms:
dst: /usr/share/doc/uptop/LICENSE dst: /usr/share/doc/uptop/LICENSE
type: doc type: doc
changelog: # Changelog generation must stay enabled: the --release-notes flag is consumed
disable: true # 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
# skips its own generation and uses the file.
+8 -3
View File
@@ -1,6 +1,11 @@
ignore: ignore:
# CVE-2026-41589: SCP path traversal in charmbracelet/wish. # SCP path traversal in charmbracelet/wish — same flaw, two ids: grype has
# 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
# No fix available for wish v1; v2 (charm.land/wish/v2) patched in 2.0.1. # (govulncheck reachability agrees). No fix for wish v1; v2
# (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
+1 -1
View File
@@ -1,5 +1,5 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:1.26-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f 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 \
+20 -1
View File
@@ -30,6 +30,8 @@ Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten fo
- **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>
@@ -79,10 +81,14 @@ 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, or attach to the container and add it in the Users tab. 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>
@@ -162,6 +168,19 @@ 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).
@@ -174,7 +193,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-Upkeep-Secret: your-secret" \ -H "X-Uptop-Secret: your-secret" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @kuma-backup.json -d @kuma-backup.json
``` ```
+4
View File
@@ -24,6 +24,10 @@ split_commits = false
protect_breaking_commits = false protect_breaking_commits = false
filter_commits = false filter_commits = false
tag_pattern = "v[0-9].*" tag_pattern = "v[0-9].*"
# rc tags are pipeline rehearsals, not releases — without this, the final
# tag's notes would only cover commits since the last rc (near-empty for
# v0.1.0). Ignored tags fold their commits into the next real release.
ignore_tags = "v.*-rc.*"
topo_order = false topo_order = false
sort_commits = "oldest" sort_commits = "oldest"
+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-Upkeep-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.
+31 -2
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 9 providers work in the YAML. The `settings` map is different per type. All 10 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,6 +149,9 @@ All 9 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
@@ -178,6 +181,14 @@ All 9 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
@@ -224,7 +235,25 @@ 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.
If something fails mid-apply, just fix the issue and run it again. It picks up where it left off. 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.
## 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-Upkeep-Secret", cfg.SharedKey) req.Header.Set("X-Uptop-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-Upkeep-Secret") receivedSecret = r.Header.Get("X-Uptop-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-Upkeep-Secret", cfg.SharedKey) req.Header.Set("X-Uptop-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-Upkeep-Secret", cfg.SharedKey) req.Header.Set("X-Uptop-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-Upkeep-Secret", cfg.SharedKey) req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
+210
View File
@@ -0,0 +1,210 @@
package importer
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTemp(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "backup.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadKumaFileMissingFile(t *testing.T) {
_, err := LoadKumaFile(filepath.Join(t.TempDir(), "nope.json"))
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoadKumaFileMalformedInput(t *testing.T) {
cases := []struct {
name string
body string
}{
{"empty file", ""},
{"truncated JSON", `{"version": "1.23", "monitorList": [`},
{"not JSON", "definitely not json"},
{"wrong root type", `[1, 2, 3]`},
{"monitorList wrong type", `{"monitorList": {"a": 1}}`},
{"monitor field wrong type", `{"monitorList": [{"id": "not-an-int"}]}`},
{"notificationList wrong type", `{"notificationList": "oops"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := LoadKumaFile(writeTemp(t, tc.body))
if err == nil {
t.Fatalf("expected parse error for %s", tc.name)
}
if !strings.Contains(err.Error(), "parse JSON") {
t.Fatalf("expected wrapped parse error, got: %v", err)
}
})
}
}
func TestLoadKumaFileNullLists(t *testing.T) {
kb, err := LoadKumaFile(writeTemp(t, `{"version": "1.23", "monitorList": null, "notificationList": null}`))
if err != nil {
t.Fatal(err)
}
backup := ConvertKuma(kb)
if len(backup.Sites) != 0 || len(backup.Alerts) != 0 {
t.Fatalf("expected empty backup, got %d sites %d alerts", len(backup.Sites), len(backup.Alerts))
}
}
func TestConvertKumaSkipsMalformedNotificationConfig(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 1, Name: "broken", Config: "{not json"},
{ID: 2, Name: "good", Config: `{"type": "discord", "ntfyserverurl": "https://example.com/hook"}`},
},
MonitorList: []KumaMonitor{
{ID: 10, Name: "site", Type: "http", URL: "https://example.com", NotificationIDs: map[string]bool{"1": true}},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 {
t.Fatalf("expected broken notification skipped, got %d alerts", len(backup.Alerts))
}
if backup.Alerts[0].Type != "discord" {
t.Fatalf("expected discord alert, got %q", backup.Alerts[0].Type)
}
if backup.Sites[0].AlertID != 0 {
t.Fatalf("site referencing skipped notification should keep AlertID 0, got %d", backup.Sites[0].AlertID)
}
}
func TestConvertKumaNtfyNotification(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 3, Name: "ntfy", Config: `{
"type": "ntfy",
"ntfyserverurl": "https://ntfy.example.com/",
"ntfytopic": "uptime",
"ntfyPriority": 4,
"ntfyAuthenticationMethod": "usernamePassword",
"ntfyusername": "u",
"ntfypassword": "p"
}`},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(backup.Alerts))
}
a := backup.Alerts[0]
if a.Type != "ntfy" {
t.Fatalf("expected ntfy, got %q", a.Type)
}
if a.Settings["url"] != "https://ntfy.example.com" {
t.Fatalf("expected trailing slash trimmed, got %q", a.Settings["url"])
}
if a.Settings["topic"] != "uptime" || a.Settings["priority"] != "4" {
t.Fatalf("unexpected settings: %v", a.Settings)
}
if a.Settings["username"] != "u" || a.Settings["password"] != "p" {
t.Fatalf("expected credentials mapped, got %v", a.Settings)
}
}
func TestConvertKumaUnknownNotificationFallsBackToWebhook(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 4, Name: "matrix", Config: `{"type": "matrix", "ntfyserverurl": "https://example.com/hook"}`},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 || backup.Alerts[0].Type != "webhook" {
t.Fatalf("expected webhook fallback, got %+v", backup.Alerts)
}
}
func TestConvertKumaHTTPMonitor(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 1, Name: "hook", Config: `{"type": "slack", "ntfyserverurl": "https://example.com/hook"}`},
},
MonitorList: []KumaMonitor{{
ID: 7,
Name: "web",
Type: "http",
URL: "https://example.com",
Interval: 60,
Timeout: 30,
MaxRetries: 2,
Method: "GET",
AcceptedCodes: []string{"200", "301"},
IgnoreTLS: true,
ExpiryNotif: true,
Active: false,
NotificationIDs: map[string]bool{"1": true},
}},
}
backup := ConvertKuma(kb)
if len(backup.Sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(backup.Sites))
}
s := backup.Sites[0]
if s.URL != "https://example.com" || !s.CheckSSL || !s.IgnoreTLS {
t.Fatalf("http fields not mapped: %+v", s)
}
if !s.Paused {
t.Fatal("inactive monitor should import paused")
}
if s.AcceptedCodes != "200,301" {
t.Fatalf("expected joined accepted codes, got %q", s.AcceptedCodes)
}
if s.AlertID != 1 {
t.Fatalf("expected alert mapped, got %d", s.AlertID)
}
}
func TestConvertKumaPushMonitorGetsToken(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{{ID: 1, Name: "push", Type: "push", Active: true}},
}
backup := ConvertKuma(kb)
token := backup.Sites[0].Token
if len(token) != 32 {
t.Fatalf("expected 32-char hex token, got %q", token)
}
}
func TestConvertKumaNonNumericNotificationID(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{{
ID: 1,
Name: "site",
Type: "http",
NotificationIDs: map[string]bool{"abc": true},
}},
}
backup := ConvertKuma(kb)
if backup.Sites[0].AlertID != 0 {
t.Fatalf("non-numeric notification ID should not map, got %d", backup.Sites[0].AlertID)
}
}
func TestConvertKumaGroupAndChildren(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{
{ID: 1, Name: "grp", Type: "group", Active: true},
{ID: 2, Name: "ping", Type: "ping", Hostname: "10.0.0.1", Parent: 1, Active: true},
},
}
backup := ConvertKuma(kb)
if backup.Sites[0].Type != "group" {
t.Fatalf("expected group type, got %q", backup.Sites[0].Type)
}
if backup.Sites[1].ParentID != 1 || backup.Sites[1].Hostname != "10.0.0.1" {
t.Fatalf("child not mapped: %+v", backup.Sites[1])
}
}
+6
View File
@@ -115,10 +115,14 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
} }
} }
// SetInsecureSkipVerify must be called before Start: the field is read by
// checker goroutines without synchronization.
func (e *Engine) SetInsecureSkipVerify(skip bool) { func (e *Engine) SetInsecureSkipVerify(skip bool) {
e.insecureSkipVerify = skip e.insecureSkipVerify = skip
} }
// SetMaintRetention must be called before Start: the field is read by the
// maintenance prune goroutine without synchronization.
func (e *Engine) SetMaintRetention(d time.Duration) { func (e *Engine) SetMaintRetention(d time.Duration) {
e.maintRetention = d e.maintRetention = d
} }
@@ -1043,6 +1047,8 @@ func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, i
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp}) e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
} }
// SetAggStrategy must be called before Start: the field is read by the probe
// aggregation path without synchronization.
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) { func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
e.aggStrategy = strategy e.aggStrategy = strategy
} }
+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-Upkeep-Secret"), s.cfg.ClusterKey) return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Uptop-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-Upkeep-Secret"), s.cfg.ClusterKey) { if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-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-Upkeep-Secret", secret) req.Header.Set("X-Uptop-Secret", secret)
} }
return http.DefaultClient.Do(req) return http.DefaultClient.Do(req)
} }
+1 -1
View File
@@ -104,7 +104,7 @@ func (m *Model) refreshLive() {
ordered = filterSites(ordered, m.filterText) ordered = filterSites(ordered, m.filterText)
} }
m.sites = ordered m.sites = ordered
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 { if m.currentTab == 0 && m.selectedID != 0 {
for i, s := range m.sites { for i, s := range m.sites {
+18 -12
View File
@@ -82,18 +82,15 @@ func (m Model) renderLogLine(line string) string {
return fmt.Sprintf(" %s %s", tag, msg) return fmt.Sprintf(" %s %s", tag, msg)
} }
func (m Model) viewLogsTab() string { // refreshLogContent rebuilds the log viewport from the full engine log list,
content := m.logViewport.View() // filtering before windowing so the entry count and "(n hidden)" reflect all
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { // logs, not just the visible viewport slice.
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks") func (m *Model) refreshLogContent() {
}
lines := strings.Split(content, "\n")
var rendered []string var rendered []string
total := 0 total := 0
shown := 0 shown := 0
for _, line := range lines { for _, line := range m.engine.GetLogs() {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
} }
@@ -106,18 +103,27 @@ func (m Model) viewLogsTab() string {
rendered = append(rendered, m.renderLogLine(line)) rendered = append(rendered, m.renderLogLine(line))
} }
m.logTotal = total
m.logShown = shown
m.logViewport.SetContent(strings.Join(rendered, "\n"))
}
func (m Model) viewLogsTab() string {
if m.logTotal == 0 {
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
}
filterLabel := "All" filterLabel := "All"
if m.logFilterImportant { if m.logFilterImportant {
filterLabel = "Important" filterLabel = "Important"
} }
header := m.st.subtleStyle.Render(fmt.Sprintf( header := m.st.subtleStyle.Render(fmt.Sprintf(
" %d entries Filter: %s", shown, filterLabel)) " %d entries Filter: %s", m.logShown, filterLabel))
if m.logFilterImportant && shown < total { if m.logFilterImportant && m.logShown < m.logTotal {
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown))
} }
m.logViewport.SetContent(strings.Join(rendered, "\n"))
return "\n" + header + "\n\n" + m.logViewport.View() return "\n" + header + "\n\n" + m.logViewport.View()
} }
+2
View File
@@ -121,6 +121,8 @@ type Model struct {
logViewport viewport.Model logViewport viewport.Model
logFilterImportant bool logFilterImportant bool
logTotal int
logShown int
historyViewport viewport.Model historyViewport viewport.Model
historyChanges []models.StateChange historyChanges []models.StateChange
+1
View File
@@ -527,6 +527,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "f": case "f":
if m.state == stateLogs { if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
return m, nil return m, nil
} }
case "tab": case "tab":