1 Commits

Author SHA1 Message Date
lerko 10f249a2ae chore: add TUI screenshots via VHS
CI / test (pull_request) Successful in 2m50s
CI / lint (pull_request) Failing after 1m12s
CI / vulncheck (pull_request) Successful in 56s
Screenshots capture 4 views: monitors dashboard (hero), detail panel,
alerts tab, and logs tab. Includes VHS tape, demo seed config, and
setup script for reproducible captures.

Also fixes latencySparkline to color DOWN checks red instead of green
— previously failed checks with 0ms latency rendered as green bars.
2026-05-28 18:30:39 -04:00
135 changed files with 5552 additions and 14258 deletions
+9 -8
View File
@@ -1,14 +1,15 @@
.git .git
.ssh/
.gitea/
tmp/ tmp/
vendor/ vendor/
*.db
*.db-journal # Security: keep sensitive/local files out of Docker build context
.ssh/
.claude/
.github/
.gitea/
CLAUDE.md
*.local.json *.local.json
*.local.md *.local.md
*.local *.local
.env *.db
.github/ *.db-journal
dist/
uptop
-44
View File
@@ -1,44 +0,0 @@
# ─── uptop configuration ───────────────────────────────────
# Export in your environment or pass via docker run --env-file.\n# Only uncomment what you need.
# ─── Core ──────────────────────────────────────────────────
UPTOP_PORT=23234 # SSH server port
UPTOP_HTTP_PORT=8080 # HTTP port (status page, push endpoints, metrics)
UPTOP_DB_TYPE=sqlite # sqlite or postgres
UPTOP_DB_DSN=/data/uptop.db # File path (SQLite) or connection string (Postgres)
# ─── Security ─────────────────────────────────────────────
# UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host # Seed first admin user on startup
# UPTOP_KEYS=/data/authorized_keys # Path to authorized_keys file (one key per line)
# UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519 # SSH host key path (auto-generated if missing)
# UPTOP_ENCRYPTION_KEY= # AES-256-GCM key for alert credentials (64 hex chars)
# # Generate: openssl rand -hex 32
# # Without this, alert passwords/tokens stored in plaintext
# UPTOP_CLUSTER_SECRET= # Shared key for cluster API + import/export auth
# ─── Status Page ──────────────────────────────────────────
# UPTOP_STATUS_ENABLED=false # Enable public status page at /status
# UPTOP_STATUS_TITLE=System Status # Status page heading
# ─── TLS ──────────────────────────────────────────────────
# UPTOP_TLS_CERT= # Path to TLS certificate (enables HTTPS)
# UPTOP_TLS_KEY= # Path to TLS private key
# ─── Clustering (leader/follower) ────────────────────────
# See docs/clustering.md for setup guides.
# UPTOP_CLUSTER_MODE=leader # leader, follower, or probe
# UPTOP_PEER_URL= # Leader HTTP URL (required for follower and probe)
# ─── Distributed Probing ─────────────────────────────────
# UPTOP_NODE_ID= # Unique node identifier (required for probe mode)
# UPTOP_NODE_NAME= # Human-readable node name
# UPTOP_NODE_REGION= # Region tag (e.g. us-east, eu-west) for monitor routing
# UPTOP_AGG_STRATEGY=any-down # How multi-probe results combine: any-down, majority-down, all-down
# ─── Advanced ─────────────────────────────────────────────
# UPTOP_INSECURE_SKIP_VERIFY=false # Skip TLS cert verification on monitored targets
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
# UPTOP_TRUSTED_PROXIES= # Comma-separated CIDRs/IPs for X-Forwarded-For trust
# UPTOP_MAINT_RETENTION=168h # How long ended maintenance windows are kept
-46
View File
@@ -1,46 +0,0 @@
name: Bug Report
about: 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
@@ -1,20 +0,0 @@
name: Feature Request
about: 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
+3 -1
View File
@@ -1,6 +1,8 @@
name: CI name: CI
on: on:
push:
branches: [main]
pull_request: pull_request:
env: env:
@@ -65,7 +67,7 @@ jobs:
go-version: "1.26" go-version: "1.26"
- name: Install govulncheck - name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck - name: Run govulncheck
run: govulncheck ./... run: govulncheck ./...
-60
View File
@@ -1,60 +0,0 @@
name: Release Binaries
on:
push:
tags:
- "v[0-9]*"
jobs:
release:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- name: Install build tools
run: apk add --no-cache git
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: release-go-${{ hashFiles('go.sum') }}
restore-keys: release-go-
- name: Install git-cliff
run: |
apk add --no-cache curl
VERSION=2.13.1
curl -sSL "https://github.com/orhun/git-cliff/releases/download/v${VERSION}/git-cliff-${VERSION}-x86_64-unknown-linux-musl.tar.gz" | tar xz -C /tmp
mv /tmp/git-cliff-*/git-cliff /usr/local/bin/
git-cliff --version
- name: Generate release notes
run: git-cliff --current --strip header -o /tmp/release-notes.md
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --release-notes=/tmp/release-notes.md
env:
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_API_URL: http://gitea:3000/api/v1
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
# which runs on GitHub Actions when the push mirror delivers the tag and
# copies this run's Gitea release assets — no PAT needed on this side.
-118
View File
@@ -1,118 +0,0 @@
name: Release Docker
on:
push:
tags:
- "v[0-9]*"
workflow_dispatch:
inputs:
tag:
description: "Image tag (e.g. 1.0.0, no v prefix). Defaults to latest commit SHA."
required: false
jobs:
docker:
runs-on: docker-builder
steps:
- uses: actions/checkout@v4
- name: Resolve image tag
id: meta
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.sha }}"
fi
else
# Docker convention: git tag v1.2.3 -> image tag 1.2.3
TAG="${{ github.ref_name }}"
TAG="${TAG#v}"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
TAGS="lerkolabs/uptop:${TAG}"
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:${TAG}"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:sha-${SHORT_SHA}"
# :latest only for real releases — rc rehearsal tags must not move it
if [ "${{ github.ref_type }}" = "tag" ]; then
case "$TAG" in
*-*) ;;
*)
TAGS="${TAGS},lerkolabs/uptop:latest"
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:latest"
;;
esac
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ secrets.GHCR_USERNAME }}
password: ${{ secrets.GHCR_TOKEN }}
# Scan must gate the push: build amd64 locally, scan it, and only then run
# the multi-arch push (amd64 layers come from the builder cache, so the
# second build only adds the arm64 work).
- 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
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
sbom: true
provenance: mode=max
tags: ${{ steps.meta.outputs.tags }}
build-args: |
VERSION=${{ steps.meta.outputs.tag }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: lerkolabs/uptop
- name: Cleanup Docker artifacts
if: always()
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 builder prune -f --keep-storage=2GB
+68
View File
@@ -0,0 +1,68 @@
name: Release
on:
push:
tags:
- "[0-9]*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: release-go-${{ hashFiles('go.sum') }}
restore-keys: release-go-
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
docker:
runs-on: ubuntu-latest
needs: [release]
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
lerkolabs/uptop:${{ github.ref_name }}
lerkolabs/uptop:latest
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
-46
View File
@@ -1,46 +0,0 @@
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
-1
View File
@@ -1 +0,0 @@
blank_issues_enabled: false
@@ -1,20 +0,0 @@
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
@@ -1,78 +0,0 @@
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."
-81
View File
@@ -1,81 +0,0 @@
name: Mirror Release to GitHub
on:
push:
tags:
- "v[0-9]*"
permissions:
contents: write
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Wait for Gitea release
id: gitea
env:
TAG: ${{ github.ref_name }}
run: |
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
# 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
ASSET_COUNT=$(echo "$RESPONSE" | jq '.assets | length')
if [ "$ASSET_COUNT" -gt 0 ] && [ "$ASSET_COUNT" -eq "$PREV_COUNT" ]; then
echo "Found release with $ASSET_COUNT assets (stable)"
break
fi
echo "Release has $ASSET_COUNT assets (was $PREV_COUNT)... attempt $i/40"
PREV_COUNT="$ASSET_COUNT"
else
echo "Waiting for Gitea release... attempt $i/40"
fi
sleep 30
done
if [ -z "$RESPONSE" ] || [ "$ASSET_COUNT" -eq 0 ]; then
echo "::error::Gitea release for ${TAG} not found or has no assets after 20 minutes"
exit 1
fi
# 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
echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md
fi
mkdir -p /tmp/assets
echo "$RESPONSE" | jq -r '.assets[] | .browser_download_url' | while read -r url; do
filename=$(basename "$url")
echo "Downloading $filename"
curl -sSL -o "/tmp/assets/${filename}" "$url"
done
- name: Delete existing GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes 2>/dev/null || true
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
PRERELEASE=""
case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$TAG" \
--notes-file /tmp/release-notes.md \
$PRERELEASE \
/tmp/assets/*
+14 -5
View File
@@ -1,3 +1,10 @@
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go
### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.exe~ *.exe~
@@ -17,14 +24,16 @@
# Go workspace file # Go workspace file
go.work go.work
# End of https://www.toptal.com/developers/gitignore/api/go
/uptop /uptop
/dist uptop.db
uptop.db*
.ssh .ssh
authorized_keys authorized_keys
tmp tmp
*.local.json *.local.json
*.local.md *.local.md
data/
.env
vhs
+11 -52
View File
@@ -1,27 +1,23 @@
version: 2 version: 2
gitea_urls: gitea_urls:
api: "{{ if index .Env \"GITEA_API_URL\" }}{{ .Env.GITEA_API_URL }}{{ else }}https://gitea.lerkolabs.com/api/v1{{ end }}" api: https://gitea.lerkolabs.com/api/v1
download: https://gitea.lerkolabs.com download: https://gitea.lerkolabs.com
release: release:
gitea: gitea:
owner: lerkolabs owner: lerko
name: uptop name: uptop
prerelease: auto
builds: builds:
- main: ./cmd/uptop - main: ./cmd/uptop/main.go
binary: uptop binary: uptop
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=1
goos: goos:
- linux - linux
- darwin
- windows
goarch: goarch:
- amd64 - amd64
- arm64
ldflags: ldflags:
- -s -w - -s -w
- -X main.version={{ .Version }} - -X main.version={{ .Version }}
@@ -33,51 +29,14 @@ builds:
archives: archives:
- formats: [tar.gz] - formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
checksum: checksum:
name_template: checksums.txt name_template: checksums.txt
nfpms: changelog:
- package_name: uptop sort: asc
file_name_template: "{{ .ConventionalFileName }}" filters:
vendor: LerkoLabs exclude:
homepage: https://gitea.lerkolabs.com/lerkolabs/uptop - "^docs:"
maintainer: Tyler Koenig <tyler@lerkolabs.com> - "^chore:"
description: Self-hosted uptime monitoring with a TUI over SSH - "^style:"
license: MIT
section: net
priority: optional
formats:
- deb
- rpm
bindir: /usr/bin
contents:
- src: ./LICENSE
dst: /usr/share/doc/uptop/LICENSE
type: doc
brews:
- name: uptop
repository:
owner: lerkolabs
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
url_template: "https://github.com/lerkolabs/uptop/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
commit_author:
name: lerkolabs-bot
email: bot@lerkolabs.com
homepage: https://github.com/lerkolabs/uptop
description: Self-hosted uptime monitoring with a TUI over SSH
license: MIT
install: |
bin.install "uptop"
test: |
system bin/"uptop", "version"
# Changelog generation must stay enabled: the --release-notes flag is consumed
# by the changelog pipe, so disabling it silently drops the git-cliff notes
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
# skips its own generation and uses the file.
-11
View File
@@ -1,11 +0,0 @@
ignore:
# 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
# scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in
# (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: GHSA-xjvp-7243-rg9h
+38 -175
View File
@@ -1,183 +1,46 @@
# Changelog # Changelog
## [Unreleased] ## [2026.05.2] — 2026-05-23
### Added ### Added
- Comprehensive test suite (94 tests across monitor, server, cluster)
- initial commit — uptime monitor (forked from go-upkeep) - golangci-lint config with CI enforcement
- enhanced dashboard with lipgloss tables, huh forms, mouse support, and animations - Gitea Actions CI pipeline (test + lint)
- upgrade users tab with lipgloss table, edit support, role select - Graceful shutdown for HTTP and SSH servers
- upgrade alerts tab with lipgloss table, click zones, colored types - Context-aware alert delivery with timeout
- widen Site struct and DB schema for ping, port, dns, group monitor types - Request size limits on all POST endpoints
- add ping, port, and DNS check routines - Constant-time secret comparison
- add ntfy notification provider with TUI support - Check interval jitter to prevent thundering herd
- add Uptime Kuma backup converter with CLI and API - `--version` flag with build metadata injection
- 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
- replace database ID column with row counter
- unify SQLite and Postgres into dialect-based SQLStore
- add error returns to all Store interface methods
- remove store global singleton, thread store explicitly
- 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
- Silent JSON unmarshal failures in alert settings
- Panic on crypto/rand failure replaced with error return
- Alert delivery errors now logged instead of swallowed
- log.Fatalf in goroutines replaced with log.Printf
- Deprecated LineUp/LineDown API calls
- forward all msg types to huh forms, improve row selection UX ### Security
- harden TLS, timeouts, validation, logging, and token generation - Cluster secret compared with crypto/subtle (timing-safe)
- add delete confirm, input validation, XSS fix, history persistence - http.MaxBytesReader on all JSON endpoints
- correctness and robustness fixes across all subsystems - ReadHeaderTimeout added to HTTP server
- 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
## [2026.05.1] — 2026-05-14
### Added
- Distributed probing with leader + probe nodes
- Config-as-code (YAML apply/export with dry-run, prune)
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
- Incident management and maintenance windows
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
## [2026.04.1] — Initial independent fork
### Added
- SSH-accessible TUI (Bubble Tea + Wish)
- 6 check types (HTTP, Push, Ping, Port, DNS, Group)
- SQLite and PostgreSQL support
- HA clustering with automatic failover
- Prometheus metrics endpoint
- Public status page
- Uptime Kuma 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
``` ```
+11 -14
View File
@@ -1,37 +1,34 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder FROM golang:1.26-alpine3.23 AS builder
RUN apk add --no-cache gcc musl-dev
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 \
go mod download go mod download
COPY . . COPY . .
ENV CGO_ENABLED=0 ENV CGO_ENABLED=1
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=none ARG COMMIT=none
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go
# --- Stage 2: Runner --- # --- Stage 2: Runner ---
FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 FROM alpine:3.23
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates && apk upgrade --no-cache RUN apk add --no-cache ca-certificates openssh-client
RUN addgroup -g 1000 -S uptop && adduser -u 1000 -S uptop -G uptop RUN mkdir /data
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/
# Set Default Configuration via ENV
# Docker users can override these in docker-compose.yml
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_PORT=23234 ENV UPTOP_PORT=23234
EXPOSE 8080 23234 EXPOSE 23234
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/health || exit 1
USER uptop
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["./uptop"] CMD ["./uptop"]
+55 -153
View File
@@ -1,126 +1,51 @@
<div align="center"> # uptop
<img src="assets/logo.svg" alt="uptop" width="320">
<p>Self-hosted uptime monitoring with a TUI over SSH.</p>
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
<p> Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`.
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/lerkolabs/uptop/releases/latest"><img src="https://img.shields.io/github/v/release/lerkolabs/uptop" alt="Latest Release"></a>
<a href="https://goreportcard.com/report/github.com/lerkolabs/uptop"><img src="https://goreportcard.com/badge/github.com/lerkolabs/uptop" alt="Go Report Card"></a>
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
</p>
<img src="assets/demo.gif" alt="uptop demo" width="800"> Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
</div>
## What is this ## What it does
An uptime monitor you manage entirely from the terminal. It runs as a server, exposes an SSH endpoint, and drops you into a full TUI — monitors, alerts, logs, nodes, all there. - **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups
- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard. - **Config as code**: define monitors in YAML, apply declaratively, version control your setup
- **HA clustering**: leader/follower with automatic failover
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. - **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
- **Public status page**: HTML + JSON, toggle with an env var
## Features - **SQLite or Postgres**: SQLite for single-node, Postgres for production
- **Uptime Kuma import**: migrate from Kuma with one command
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
- **HA clustering** — leader/follower with automatic failover
- **Prometheus metrics** — `/metrics` endpoint (`UPTOP_METRICS_PUBLIC=true` to expose without auth)
- **Public status page** — HTML + JSON, toggle with an env var
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
- **Uptime Kuma import** — migrate from Kuma with one command
> Group monitors roll up child status for display but don't fire their own alerts yet — attach alerts to the children.
## Screenshots
<table>
<tr>
<td><img src="assets/monitors.png" alt="monitors + log sidebar" width="400"></td>
<td><img src="assets/detail.png" alt="inline detail panel" width="400"></td>
</tr>
<tr>
<td><img src="assets/fulldetail.png" alt="full-screen detail" width="400"></td>
<td><img src="assets/settings.png" alt="settings / alerts" width="400"></td>
</tr>
<tr>
<td><img src="assets/maint.png" alt="maintenance windows" width="400"></td>
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
</tr>
</table>
### Themes
Five built-in themes: Flexoki Dark, Tokyo Night, Catppuccin Mocha, Nord, Gruvbox. Press `T` to cycle.
<p align="center">
<img src="assets/themes.png" alt="all five themes" width="800">
</p>
## Quick start ## 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: Seed some demo data to see it in action:
```bash ```bash
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop -demo go run cmd/uptop/main.go -demo
``` ```
## Install ## Install
<details> ### From source
<summary><strong>Docker (recommended)</strong></summary>
```yaml
services:
uptop:
image: lerkolabs/uptop:latest
restart: unless-stopped
ports:
- "23234:23234"
- "8080:8080"
environment:
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_STATUS_ENABLED=true
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
volumes:
- ./data:/data
sysctls:
- net.ipv4.ping_group_range=0 2147483647
```
First run: set `UPTOP_ADMIN_KEY` to your SSH public key.
The `sysctls` line enables unprivileged ICMP inside the container — without it, ping monitors get no response and silently report DOWN.
</details>
<details>
<summary><strong>Binary (Linux, macOS, Windows)</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`.
</details>
<details>
<summary><strong>From source</strong></summary>
```bash ```bash
go install gitea.lerkolabs.com/lerkolabs/uptop/cmd/uptop@latest go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
``` ```
</details> ### Docker
**Upgrading:** Pull the new image (or binary) and restart. Database migrations run automatically on startup. ```bash
docker pull lerko/uptop:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
```
### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
## Config as code ## Config as code
@@ -138,11 +63,35 @@ uptop apply -f monitors.yaml --dry-run # see what would change
uptop apply -f monitors.yaml --prune # delete anything not in the YAML uptop apply -f monitors.yaml --prune # delete anything not in the YAML
``` ```
Full reference in [docs/config-as-code.md](docs/config-as-code.md). See [docs/config-as-code.md](docs/config-as-code.md) for the full reference.
## Docker
```yaml
services:
monitor:
build: .
restart: unless-stopped
stdin_open: true
tty: true
ports:
- "23234:23234"
- "8080:8080"
volumes:
- ./data:/data
- ./ssh_keys:/app/.ssh
environment:
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_STATUS_ENABLED=true
- UPTOP_CLUSTER_SECRET=change-me
```
First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
## Environment variables ## Environment variables
| Variable | Default | Description | | Variable | Default | What it does |
|---|---|---| |---|---|---|
| `UPTOP_PORT` | `23234` | SSH server port | | `UPTOP_PORT` | `23234` | SSH server port |
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) | | `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
@@ -150,57 +99,10 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
| `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string | | `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
| `UPTOP_STATUS_ENABLED` | `false` | Enable public status page | | `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPTOP_STATUS_TITLE` | `System Status` | Status page title | | `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
| `UPTOP_ENCRYPTION_KEY` | | AES-256-GCM key for alert credentials ([details](#encryption)) | | `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPTOP_CLUSTER_MODE` | `leader` | `leader`, `follower`, or `probe` | | `UPTOP_PEER_URL` | | Leader URL for follower nodes |
| `UPTOP_PEER_URL` | | Leader URL for follower and probe nodes |
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth | | `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `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_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
| `UPTOP_METRICS_PUBLIC` | `false` | Expose `/metrics` without auth |
| `UPTOP_MAINT_RETENTION` | `168h` | How long ended maintenance windows are kept |
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
### Running behind a reverse proxy
By default uptop ignores the `X-Forwarded-For` header and rate-limits by the direct connection address — so a client can't spoof the header to bypass limits. If uptop sits behind a reverse proxy (nginx, Caddy, Cloudflare, an ALB), set `UPTOP_TRUSTED_PROXIES` to the proxy's address(es) so the real client IP is used instead:
# single nginx/Caddy on the same host
UPTOP_TRUSTED_PROXIES=127.0.0.1
# a proxy subnet, or Cloudflare ranges
UPTOP_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12
Only requests whose immediate peer is in this list have their `X-Forwarded-For` honored (right-most non-trusted hop wins). Bare IPs are treated as single hosts; invalid entries are warned about and skipped. Leave it unset if uptop is exposed directly.
### Encryption
Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook URLs, API tokens) at rest with AES-256-GCM. Generate a key:
openssl rand -hex 32
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 (`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.
## 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).
See [docs/clustering.md](docs/clustering.md) for setup guides, or the working examples in [`deploy/`](deploy/).
## Migrating from Uptime Kuma ## Migrating from Uptime Kuma
@@ -208,7 +110,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
``` ```
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

-10
View File
@@ -1,10 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 604 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

-51
View File
@@ -1,51 +0,0 @@
[changelog]
header = """
# Changelog\n
"""
body = """
{% if version %}\
## [{{ version }}] — {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | trim }}\
{% endfor %}
{% endfor %}\n
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
protect_breaking_commits = false
filter_commits = false
# Only final tags count as releases — rc rehearsal tags must not become
# 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
sort_commits = "oldest"
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Changed" },
{ message = "^refactor", group = "Changed" },
{ message = "^security", group = "Security" },
{ message = "^polish", group = "Changed" },
{ body = "BREAKING", group = "Breaking" },
{ footer = "BREAKING.CHANGE", group = "Breaking" },
{ message = "^docs", skip = true },
{ message = "^style", skip = true },
{ message = "^chore", skip = true },
{ message = "^ci", skip = true },
{ message = "^test", skip = true },
{ message = "^build", skip = true },
]
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"net"
"os"
"strconv"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
)
type appConfig struct {
Port int
SSHHostKey string
DBType string
DBDSN string
HTTPPort int
TLSCert string
TLSKey string
StatusEnabled bool
StatusTitle string
ClusterMode string
ClusterSecret string
PeerURL string
NodeID string
NodeName string
NodeRegion string
AggStrategy string
AllowPrivateTargets bool
InsecureSkipVerify bool
MaintRetention time.Duration
EncryptionKey string
MetricsPublic bool
CORSOrigin string
TrustedProxies []*net.IPNet
AdminKey string
KeysFile string
}
func parseConfig() appConfig {
cfg := appConfig{
Port: 23234,
SSHHostKey: ".ssh/id_ed25519",
DBType: "sqlite",
DBDSN: "uptop.db",
HTTPPort: 8080,
StatusTitle: "System Status",
ClusterMode: "leader",
MaintRetention: 7 * 24 * time.Hour,
}
if v := os.Getenv("UPTOP_PORT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Port = n
}
}
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
cfg.DBType = v
}
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
cfg.DBDSN = v
}
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.HTTPPort = n
}
}
if os.Getenv("UPTOP_STATUS_ENABLED") == "true" {
cfg.StatusEnabled = true
}
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
cfg.StatusTitle = v
}
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
cfg.ClusterMode = v
}
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
cfg.PeerURL = v
}
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
cfg.ClusterSecret = v
}
cfg.NodeID = os.Getenv("UPTOP_NODE_ID")
cfg.NodeName = os.Getenv("UPTOP_NODE_NAME")
cfg.NodeRegion = os.Getenv("UPTOP_NODE_REGION")
cfg.AggStrategy = os.Getenv("UPTOP_AGG_STRATEGY")
cfg.AllowPrivateTargets = os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
cfg.InsecureSkipVerify = os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true"
cfg.MetricsPublic = os.Getenv("UPTOP_METRICS_PUBLIC") == "true"
cfg.EncryptionKey = os.Getenv("UPTOP_ENCRYPTION_KEY")
cfg.TLSCert = os.Getenv("UPTOP_TLS_CERT")
cfg.TLSKey = os.Getenv("UPTOP_TLS_KEY")
cfg.CORSOrigin = os.Getenv("UPTOP_CORS_ORIGIN")
cfg.TrustedProxies = parseTrustedProxies(os.Getenv("UPTOP_TRUSTED_PROXIES"))
cfg.SSHHostKey = envOrDefault("UPTOP_SSH_HOST_KEY", cfg.SSHHostKey)
cfg.AdminKey = os.Getenv("UPTOP_ADMIN_KEY")
cfg.KeysFile = os.Getenv("UPTOP_KEYS")
if v := os.Getenv("UPTOP_MAINT_RETENTION"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.MaintRetention = d
}
}
return cfg
}
func (c appConfig) serverConfig(quietHTTPLog bool) server.ServerConfig {
return server.ServerConfig{
Port: c.HTTPPort,
EnableStatus: c.StatusEnabled,
Title: c.StatusTitle,
ClusterKey: c.ClusterSecret,
TLSCert: c.TLSCert,
TLSKey: c.TLSKey,
ClusterMode: c.ClusterMode,
MetricsPublic: c.MetricsPublic,
CORSOrigin: c.CORSOrigin,
TrustedProxies: c.TrustedProxies,
QuietHTTPLog: quietHTTPLog,
}
}
-115
View File
@@ -1,115 +0,0 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
"github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh"
)
// kcMockStore embeds BaseMock for default no-ops; only GetAllUsers is
// overridden because the tests mutate users/err between calls.
type kcMockStore struct {
storetest.BaseMock
users []models.User
err error
}
func (m *kcMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, m.err }
func testKey(t *testing.T) (string, ssh.PublicKey) {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sk, err := gossh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return string(gossh.MarshalAuthorizedKey(sk)), sk
}
func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
authorized, known := testKey(t)
_, unknown := testKey(t)
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
if !kc.IsAllowed(context.Background(), known) {
t.Error("known key denied")
}
if kc.IsAllowed(context.Background(), unknown) {
t.Error("unknown key allowed")
}
}
func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
authorized, known := testKey(t)
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
// DB goes down and the cache goes stale: a transient error must not lock
// every admin out — the previous key set stays in effect.
ms.err = errors.New("db down")
kc.mu.Lock()
kc.updated = time.Now().Add(-time.Hour)
kc.mu.Unlock()
if !kc.IsAllowed(context.Background(), known) {
t.Error("transient refresh error locked out a previously valid key")
}
}
func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
authorized, known := testKey(t)
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
// Revocation happened (Invalidate) and the DB is unreachable for the
// re-read: the revoked key must NOT keep working off the stale cache.
ms.err = errors.New("db down")
kc.Invalidate()
if kc.IsAllowed(context.Background(), known) {
t.Error("revoked key still allowed while DB is down — fails open")
}
}
func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
authorized, known := testKey(t)
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
kc := newKeyCache(ms)
s := &userInvalidatingStore{Store: ms, kc: kc}
if !kc.IsAllowed(context.Background(), known) {
t.Fatal("known key denied on first refresh")
}
// Revoke the user; DB unreachable immediately after. The cached key must
// be gone the moment the delete returns.
if err := s.DeleteUser(context.Background(), 1); err != nil {
t.Fatal(err)
}
ms.users = nil
ms.err = errors.New("db down")
if kc.IsAllowed(context.Background(), known) {
t.Error("deleted user's key still allowed from stale cache")
}
}
+183 -272
View File
@@ -6,26 +6,25 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log"
"net"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime/debug" "strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster" "gitea.lerkolabs.com/lerko/uptop/internal/cluster"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config" "gitea.lerkolabs.com/lerko/uptop/internal/config"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server" "gitea.lerkolabs.com/lerko/uptop/internal/server"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui" "gitea.lerkolabs.com/lerko/uptop/internal/tui"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
@@ -40,41 +39,8 @@ 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)" {
mv = strings.TrimPrefix(mv, "v")
// Pseudo-versions (e.g. "0.1.1-0.20260620165311-5ca534b0b100+dirty")
// are noisy in the TUI footer. Extract just the base semver.
if i := strings.Index(mv, "-0."); i > 0 {
mv = mv[:i]
}
mv = strings.TrimSuffix(mv, "+dirty")
version = mv
}
for _, s := range info.Settings {
switch s.Key {
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{ log.SetOutput(os.Stderr)
Level: slog.LevelInfo,
})))
if len(os.Args) >= 2 { if len(os.Args) >= 2 {
switch os.Args[1] { switch os.Args[1] {
@@ -84,50 +50,23 @@ func main() {
case "export": case "export":
runExport(os.Args[2:]) runExport(os.Args[2:])
return return
case "version", "--version", "-v", "-version": case "version", "--version", "-v":
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 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 {
@@ -146,40 +85,7 @@ func redactDSN(dsn string) string {
return u.String() return u.String()
} }
// parseTrustedProxies turns UPTOP_TRUSTED_PROXIES (comma-separated CIDRs or func openStore(dbType, dsn string) store.Store {
// bare IPs) into networks the rate limiter trusts to set X-Forwarded-For. Bare
// IPs are treated as single-host ranges. Invalid entries are warned about and
// skipped, so a typo degrades to "ignore XFF" (safe) rather than aborting boot.
func parseTrustedProxies(raw string) []*net.IPNet {
if strings.TrimSpace(raw) == "" {
return nil
}
var cidrs []*net.IPNet
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if ip := net.ParseIP(part); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
part = fmt.Sprintf("%s/%d", part, bits)
}
}
_, ipnet, err := net.ParseCIDR(part)
if err != nil {
slog.Warn("ignoring invalid UPTOP_TRUSTED_PROXIES entry", "entry", part, "err", err) //nolint:gosec // structured slog, not format string
continue
}
cidrs = append(cidrs, ipnet)
}
return cidrs
}
func openStore(ctx context.Context, dbType, dsn string) store.Store {
var ss *store.SQLStore var ss *store.SQLStore
var err error var err error
if dbType == "postgres" { if dbType == "postgres" {
@@ -188,21 +94,21 @@ func openStore(ctx context.Context, dbType, dsn string) store.Store {
ss, err = store.NewSQLiteStore(dsn) ss, err = store.NewSQLiteStore(dsn)
} }
if err != nil { if err != nil {
slog.Error("database connection failed", "err", err) fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" { if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(encKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
} else { } else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted") fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
} }
if err := ss.Init(ctx); err != nil { if err := ss.Init(); err != nil {
slog.Error("database init failed", "err", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
return ss return ss
@@ -213,7 +119,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 (sqlite or postgres)") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
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
@@ -223,21 +129,20 @@ func runApply(args []string) {
os.Exit(1) os.Exit(1)
} }
ctx := context.Background() s := openStore(*dbType, *dsn)
s := openStore(ctx, *dbType, *dsn)
f, err := config.LoadFile(*filePath) f, err := config.LoadFile(*filePath)
if err != nil { if err != nil {
slog.Error("config load failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{ changes, err := config.Apply(s, f, config.ApplyOpts{
DryRun: *dryRun, DryRun: *dryRun,
Prune: *prune, Prune: *prune,
}) })
if err != nil { if err != nil {
slog.Error("config apply failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -247,28 +152,27 @@ 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 (sqlite or postgres)") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
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
ctx := context.Background() s := openStore(*dbType, *dsn)
s := openStore(ctx, *dbType, *dsn)
f, err := config.Export(ctx, s) f, err := config.Export(s)
if err != nil { if err != nil {
slog.Error("export failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := config.WriteFile(f, *outPath); err != nil { if err := config.WriteFile(f, *outPath); err != nil {
slog.Error("export write failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }
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 (sqlite or postgres)") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
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)
@@ -279,12 +183,10 @@ func runMigrateSecrets(args []string) {
} }
enc, err := store.NewEncryptor(encKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ctx := context.Background()
var ss *store.SQLStore var ss *store.SQLStore
if *dbType == "postgres" { if *dbType == "postgres" {
ss, err = store.NewPostgresStore(*dsn) ss, err = store.NewPostgresStore(*dsn)
@@ -292,25 +194,25 @@ func runMigrateSecrets(args []string) {
ss, err = store.NewSQLiteStore(*dsn) ss, err = store.NewSQLiteStore(*dsn)
} }
if err != nil { if err != nil {
slog.Error("database connection failed", "err", err) fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := ss.Init(ctx); err != nil { if err := ss.Init(); err != nil {
slog.Error("database init failed", "err", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
alerts, err := ss.GetAllAlerts()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading alerts: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
alerts, err := ss.GetAllAlerts(ctx)
if err != nil {
slog.Error("failed to load alerts", "err", err)
os.Exit(1)
}
migrated := 0 migrated := 0
for _, a := range alerts { for _, a := range alerts {
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil { if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil {
slog.Error("alert migration failed", "alert", a.Name, "err", err) fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err)
os.Exit(1) os.Exit(1)
} }
migrated++ migrated++
@@ -319,19 +221,64 @@ func runMigrateSecrets(args []string) {
} }
func runServe(args []string) { func runServe(args []string) {
cfg := parseConfig() portVal := 23234
dbType := "sqlite"
dbDSN := "uptop.db"
httpPort := 8080
enableStatus := false
statusTitle := "System Status"
clusterMode := "leader"
clusterPeer := ""
clusterKey := ""
if cfg.ClusterMode == "probe" { if v := os.Getenv("UPTOP_PORT"); v != "" {
if cfg.NodeID == "" { if p, err := strconv.Atoi(v); err == nil {
portVal = p
}
}
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
dbType = v
}
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
dbDSN = v
}
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
httpPort = p
}
}
if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
enableStatus = true
}
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
statusTitle = v
}
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
clusterMode = v
}
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
clusterPeer = v
}
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
clusterKey = v
}
nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
if clusterMode == "probe" {
if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
os.Exit(1) os.Exit(1)
} }
if cfg.PeerURL == "" { if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", cfg.NodeID, cfg.NodeRegion) fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -342,28 +289,29 @@ func runServe(args []string) {
cancel() cancel()
}() }()
if cfg.AllowPrivateTargets { probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks") if probeAllowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
} }
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{ if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
NodeID: cfg.NodeID, NodeID: nodeID,
NodeName: cfg.NodeName, NodeName: nodeName,
Region: cfg.NodeRegion, Region: nodeRegion,
LeaderURL: cfg.PeerURL, LeaderURL: clusterPeer,
SharedKey: cfg.ClusterSecret, SharedKey: clusterKey,
Interval: 30, Interval: 30,
AllowPrivateTargets: cfg.AllowPrivateTargets, AllowPrivateTargets: probeAllowPrivate,
}); err != nil { }); err != nil {
slog.Error("probe failed", "err", err) fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
} }
return return
} }
fs := flag.NewFlagSet("serve", flag.ExitOnError) fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", cfg.Port, "SSH Port") port := fs.Int("port", portVal, "SSH Port")
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)") flagDBType := fs.String("db-type", dbType, "Database type")
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN") flagDSN := fs.String("dsn", 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")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
@@ -372,92 +320,101 @@ func runServe(args []string) {
var dbErr error var dbErr error
if *flagDBType == "postgres" { if *flagDBType == "postgres" {
ss, dbErr = store.NewPostgresStore(*flagDSN) ss, dbErr = store.NewPostgresStore(*flagDSN)
slog.Info("database connected", "type", "postgres", "dsn", redactDSN(*flagDSN)) fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
} else { } else {
ss, dbErr = store.NewSQLiteStore(*flagDSN) ss, dbErr = store.NewSQLiteStore(*flagDSN)
slog.Info("database connected", "type", "sqlite", "dsn", *flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN)
} }
if dbErr != nil { if dbErr != nil {
slog.Error("database connection failed", "err", dbErr) fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
os.Exit(1) os.Exit(1)
} }
defer ss.Close() defer ss.Close()
if cfg.EncryptionKey != "" { if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(cfg.EncryptionKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
} else { } else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted") fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
} }
kc := newKeyCache(ss) var s store.Store = ss
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc} if err := s.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Init(ctx); err != nil {
slog.Error("database init failed", "err", err)
os.Exit(1) os.Exit(1)
} }
if *demo { if *demo {
seedDemoData(ctx, s) seedDemoData(s)
} }
seedKeysFromEnv(ctx, s) seedKeysFromEnv(s)
if *importKuma != "" { if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma) kb, err := importer.LoadKumaFile(*importKuma)
if err != nil { if err != nil {
slog.Error("kuma import failed", "err", err) fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
backup := importer.ConvertKuma(kb) backup := importer.ConvertKuma(kb)
if err := s.ImportData(ctx, backup); err != nil { if err := s.ImportData(backup); err != nil {
slog.Error("import failed", "err", err) fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
} }
if cfg.AllowPrivateTargets { allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks") if allowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
} }
eng := monitor.NewEngineWithOpts(s, cfg.AllowPrivateTargets) eng := monitor.NewEngineWithOpts(s, allowPrivate)
if cfg.InsecureSkipVerify { if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true) eng.SetInsecureSkipVerify(true)
} }
if cfg.AggStrategy != "" { if aggStrategy != "" {
eng.SetAggStrategy(monitor.AggregationStrategy(cfg.AggStrategy)) eng.SetAggStrategy(monitor.AggregationStrategy(aggStrategy))
} }
eng.SetMaintRetention(cfg.MaintRetention)
eng.InitHistory(ctx) ctx, cancel := context.WithCancel(context.Background())
eng.InitLogs(ctx) defer cancel()
eng.InitAlertHealth(ctx)
eng.InitHistory()
eng.InitLogs()
eng.Start(ctx) eng.Start(ctx)
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) tlsCert := os.Getenv("UPTOP_TLS_CERT")
tlsKey := os.Getenv("UPTOP_TLS_KEY")
httpSrv := server.Start(cfg.serverConfig(localTUI), s, eng) httpSrv := server.Start(server.ServerConfig{
Port: httpPort,
EnableStatus: enableStatus,
Title: statusTitle,
ClusterKey: clusterKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
ClusterMode: clusterMode,
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
}, s, eng)
cluster.Start(ctx, cluster.Config{ cluster.Start(ctx, cluster.Config{
Mode: cfg.ClusterMode, Mode: clusterMode,
PeerURL: cfg.PeerURL, PeerURL: clusterPeer,
SharedKey: cfg.ClusterSecret, SharedKey: clusterKey,
}, eng) }, eng)
kc := newKeyCache(s)
sshSrv := startSSHServer(*port, s, eng, kc) sshSrv := startSSHServer(*port, s, eng, kc)
if localTUI { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
slog.Error("TUI failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
} }
} else { } else {
fmt.Println("uptop running in HEADLESS mode") fmt.Println("uptop running in HEADLESS mode")
@@ -468,18 +425,16 @@ func runServe(args []string) {
} }
cancel() cancel()
eng.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer shutdownCancel()
if httpSrv != nil { if httpSrv != nil {
if err := httpSrv.Shutdown(shutdownCtx); err != nil { if err := httpSrv.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP shutdown failed", "err", err) log.Printf("HTTP shutdown error: %v", err)
} }
} }
if sshSrv != nil { if sshSrv != nil {
if err := sshSrv.Shutdown(shutdownCtx); err != nil { if err := sshSrv.Shutdown(shutdownCtx); err != nil {
slog.Error("SSH shutdown failed", "err", err) log.Printf("SSH shutdown error: %v", err)
} }
} }
} }
@@ -489,57 +444,57 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")), wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return kc.IsAllowed(ctx, key) return kc.IsAllowed(key)
}), }),
wish.WithMiddleware( wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} return tui.InitialModel(false, db, eng), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}), }),
), ),
) )
if err != nil { if err != nil {
slog.Error("SSH server failed", "err", err) fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
return nil return nil
} }
go func() { go func() {
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
slog.Error("SSH server failed", "err", err) log.Printf("SSH server error: %v", err)
} }
}() }()
return s return s
} }
func seedDemoData(ctx context.Context, s store.Store) { func seedDemoData(s store.Store) {
existing, _ := s.GetSites(ctx) existing, _ := s.GetSites()
if len(existing) > 0 { if len(existing) > 0 {
return return
} }
fmt.Println("Seeding demo data...") fmt.Println("Seeding demo data...")
if err := s.AddAlert(ctx, "Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil { if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
if err := s.AddAlert(ctx, "Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil { if err := s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
if err := s.AddAlert(ctx, "Email Oncall", "email", map[string]string{ if err := s.AddAlert("Email Oncall", "email", map[string]string{
"host": "smtp.example.com", "port": "587", "host": "smtp.example.com", "port": "587",
"user": "oncall@example.com", "pass": "replace-me", "user": "oncall@example.com", "pass": "replace-me",
"from": "oncall@example.com", "to": "team@example.com", "from": "oncall@example.com", "to": "team@example.com",
}); err != nil { }); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
alerts, _ := s.GetAllAlerts(ctx) alerts, _ := s.GetAllAlerts()
alertID := 0 alertID := 0
if len(alerts) > 0 { if len(alerts) > 0 {
alertID = alerts[0].ID alertID = alerts[0].ID
} }
demoSites := []models.SiteConfig{ demoSites := []models.Site{
{Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2}, {Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2},
{Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}, {Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3},
{Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}, {Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1},
@@ -552,8 +507,8 @@ func seedDemoData(ctx context.Context, s store.Store) {
{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}, {Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7},
} }
for _, site := range demoSites { for _, site := range demoSites {
if err := s.AddSite(ctx, site); err != nil { if err := s.AddSite(site); err != nil {
slog.Error("demo seed failed", "step", "add site", "site", site.Name, "err", err) log.Printf("demo seed: add site %q: %v", site.Name, err)
} }
} }
} }
@@ -570,13 +525,9 @@ func newKeyCache(db store.Store) *keyCache {
return &keyCache{db: db, ttl: 30 * time.Second} return &keyCache{db: db, ttl: 30 * time.Second}
} }
func (c *keyCache) refresh(ctx context.Context) { func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers(ctx) users, err := c.db.GetAllUsers()
if err != nil { if err != nil {
// Keep the previous key set: a transient DB error must not lock every
// admin out. Revocation still fails closed because Invalidate clears
// the set immediately.
slog.Error("SSH key cache refresh failed", "err", err)
return return
} }
keys := make([]ssh.PublicKey, 0, len(users)) keys := make([]ssh.PublicKey, 0, len(users))
@@ -593,24 +544,19 @@ func (c *keyCache) refresh(ctx context.Context) {
c.mu.Unlock() c.mu.Unlock()
} }
// Invalidate clears the cached key set, not just the timestamp. If the
// refresh that follows a user revocation fails, auth fails closed (everyone
// re-authenticates after the next successful refresh) instead of the revoked
// key silently continuing to work off the stale cache.
func (c *keyCache) Invalidate() { func (c *keyCache) Invalidate() {
c.mu.Lock() c.mu.Lock()
c.keys = nil
c.updated = time.Time{} c.updated = time.Time{}
c.mu.Unlock() c.mu.Unlock()
} }
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool { func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
c.mu.RLock() c.mu.RLock()
stale := time.Since(c.updated) > c.ttl stale := time.Since(c.updated) > c.ttl
c.mu.RUnlock() c.mu.RUnlock()
if stale { if stale {
c.refresh(ctx) c.refresh()
} }
c.mu.RLock() c.mu.RLock()
@@ -623,40 +569,7 @@ func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) boo
return false return false
} }
// userInvalidatingStore drops the SSH key cache whenever the user table func seedKeysFromEnv(s store.Store) {
// changes, so a revocation takes effect on the next connection attempt
// instead of after the cache TTL — and fails closed if the DB is unreachable
// when that next attempt re-reads the table.
type userInvalidatingStore struct {
store.Store
kc *keyCache
}
func (s *userInvalidatingStore) AddUser(ctx context.Context, username, publicKey, role string) error {
err := s.Store.AddUser(ctx, username, publicKey, role)
s.kc.Invalidate()
return err
}
func (s *userInvalidatingStore) UpdateUser(ctx context.Context, id int, username, publicKey, role string) error {
err := s.Store.UpdateUser(ctx, id, username, publicKey, role)
s.kc.Invalidate()
return err
}
func (s *userInvalidatingStore) DeleteUser(ctx context.Context, id int) error {
err := s.Store.DeleteUser(ctx, id)
s.kc.Invalidate()
return err
}
func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Backup) error {
err := s.Store.ImportData(ctx, data)
s.kc.Invalidate()
return err
}
func seedKeysFromEnv(ctx context.Context, s store.Store) {
var keys []string var keys []string
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" { if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
@@ -665,9 +578,7 @@ func seedKeysFromEnv(ctx context.Context, s store.Store) {
if path := os.Getenv("UPTOP_KEYS"); path != "" { if path := os.Getenv("UPTOP_KEYS"); path != "" {
f, err := os.Open(filepath.Clean(path)) f, err := os.Open(filepath.Clean(path))
if err != nil { if err == nil {
slog.Warn("failed to open UPTOP_KEYS file", "path", path, "err", err) //nolint:gosec // structured slog, not format string
} else {
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@@ -684,9 +595,9 @@ func seedKeysFromEnv(ctx context.Context, s store.Store) {
return return
} }
existing, err := s.GetAllUsers(ctx) existing, err := s.GetAllUsers()
if err != nil { if err != nil {
slog.Warn("could not check existing users", "err", err) fmt.Fprintf(os.Stderr, "warning: could not check existing users: %v\n", err)
return return
} }
@@ -702,8 +613,8 @@ func seedKeysFromEnv(ctx context.Context, s store.Store) {
} }
username := usernameFromKey(key, i, len(existing)+added) username := usernameFromKey(key, i, len(existing)+added)
if err := s.AddUser(ctx, username, key, "admin"); err != nil { if err := s.AddUser(username, key, "admin"); err != nil {
slog.Warn("failed to seed user", "user", username, "err", err) //nolint:gosec // structured slog, not format string fmt.Fprintf(os.Stderr, "warning: failed to seed user %q: %v\n", username, err)
continue continue
} }
fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != "")) fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != ""))
@@ -3,10 +3,8 @@ services:
# LEADER NODE # LEADER NODE
# ------------------------- # -------------------------
leader: leader:
image: lerkolabs/uptop:latest build: .
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
@@ -20,7 +18,7 @@ services:
# Cluster Config # Cluster Config
- UPTOP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=mysecret
depends_on: depends_on:
- leader-db - leader-db
stdin_open: true stdin_open: true
@@ -40,10 +38,8 @@ services:
# FOLLOWER NODE # FOLLOWER NODE
# ------------------------- # -------------------------
follower: follower:
image: lerkolabs/uptop:latest build: .
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)
@@ -57,7 +53,7 @@ services:
# Cluster Config # Cluster Config
- UPTOP_CLUSTER_MODE=follower - UPTOP_CLUSTER_MODE=follower
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally # IMPORTANT: Uses the Service Name "leader" to connect internally
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
depends_on: depends_on:
@@ -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:
@@ -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 --- # --- Web Server Configuration (Phase 4) ---
- 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
@@ -1,11 +1,9 @@
services: services:
leader: leader:
image: lerkolabs/uptop:latest build: .
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
- UPTOP_AGG_STRATEGY=any-down - UPTOP_AGG_STRATEGY=any-down
- UPTOP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
ports: ports:
@@ -13,29 +11,25 @@ services:
- "23234:23234" - "23234:23234"
probe-us-east: probe-us-east:
image: lerkolabs/uptop:latest build: .
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
- UPTOP_NODE_NAME=US East Probe - UPTOP_NODE_NAME=US East Probe
- UPTOP_NODE_REGION=us-east - UPTOP_NODE_REGION=us-east
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
probe-eu-west: probe-eu-west:
image: lerkolabs/uptop:latest build: .
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
- UPTOP_NODE_NAME=EU West Probe - UPTOP_NODE_NAME=EU West Probe
- UPTOP_NODE_REGION=eu-west - UPTOP_NODE_REGION=eu-west
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
@@ -1,17 +1,10 @@
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
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
sysctls:
- net.ipv4.ping_group_range=0 2147483647
tmpfs:
- /tmp
ports: ports:
- "23234:23234" - "23234:23234"
- "8080:8080" - "8080:8080"
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -e
if [ ! -w /data ]; then
echo "ERROR: /data is not writable by uptop user (UID $(id -u))." >&2
echo "" >&2
echo "If upgrading from a previous version that ran as root:" >&2
echo " docker run --rm -v <your_volume>:/data alpine chown -R 1000:1000 /data" >&2
exit 1
fi
mkdir -p /data/.ssh
exec "$@"
-83
View File
@@ -1,83 +0,0 @@
# Clustering
uptop supports three deployment modes for different reliability and coverage needs.
## Single node (default)
Out of the box, uptop runs as a standalone leader. One process, one database, runs all checks. No clustering config needed.
## Leader + follower (HA failover)
A follower is a standby replica that takes over if the leader goes down.
**How it works:**
- The follower polls the leader's `/api/health` endpoint every 5 seconds
- After 3 consecutive failures (15 seconds), the follower promotes itself and starts running checks
- When the leader recovers, the follower detects it and goes back to standby
- Both nodes have their own database — they do not share state
**Limitations:**
- During a network partition where both nodes are healthy, both will run checks and fire alerts independently. There is no leader fencing — the follower has no way to confirm the leader is actually down vs. unreachable from its perspective. This window lasts until the partition heals, at which point the follower detects the leader and steps down.
- Expect duplicate alerts and doubled check history entries during a split-brain event. Alerts are idempotent for most providers (a second "site is down" notification is noisy but not harmful).
- Failover takeover time is ~15 seconds (3 missed polls × 5 second interval). This is not configurable.
**Required env vars:**
| Node | Variable | Value |
|------|----------|-------|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
| Follower | `UPTOP_CLUSTER_MODE` | `follower` |
| Follower | `UPTOP_PEER_URL` | Leader's HTTP URL (e.g. `http://leader:8080`) |
See [`deploy/docker-compose.cluster.yml`](../deploy/docker-compose.cluster.yml) for a working example.
## Leader + probes (distributed monitoring)
Probes are lightweight, stateless nodes that run checks from different locations and report results back to the leader.
**How it works:**
- A probe registers with the leader on startup
- Every 30 seconds, it fetches check assignments filtered by its region
- It runs the assigned checks (up to 10 concurrent) and posts results back
- The leader aggregates results from all probes and triggers alerts based on the aggregation strategy
- Probes have no database, no UI, and no configuration of their own
**Required env vars:**
| Node | Variable | Value |
|------|----------|-------|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
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.
## Aggregation strategies
When multiple probes check the same monitor, the leader combines their results:
| Strategy | Behavior |
|----------|----------|
| `any-down` (default) | DOWN if **any** probe reports down |
| `majority-down` | DOWN if **most** probes report down |
| `all-down` | DOWN only if **all** probes report down |
Set via `UPTOP_AGG_STRATEGY` on the leader.
## Follower vs probe
| | Follower | Probe |
|---|---|---|
| **Purpose** | Failover / redundancy | Distributed checks from multiple regions |
| **Database** | Own database (independent) | None (stateless) |
| **Runs checks** | Only when leader is down | Always, on assigned monitors |
| **Scales to** | 1 follower per leader | Many probes per leader |
## Security
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints reject all requests (fail closed); only `/api/health` stays open.
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
+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
+5 -10
View File
@@ -1,6 +1,6 @@
module gitea.lerkolabs.com/lerkolabs/uptop module gitea.lerkolabs.com/lerko/uptop
go 1.26.4 go 1.26.3
require ( require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
@@ -13,11 +13,10 @@ require (
github.com/lib/pq v1.11.1 github.com/lib/pq v1.11.1
github.com/lrstanley/bubblezone v1.0.0 github.com/lrstanley/bubblezone v1.0.0
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-sqlite3 v1.14.33
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus-community/pro-bing v0.8.0
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.52.0
) )
require ( require (
@@ -49,18 +48,14 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
+4 -38
View File
@@ -64,12 +64,8 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
@@ -82,6 +78,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -92,14 +90,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -113,8 +107,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -131,31 +125,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+6 -119
View File
@@ -3,40 +3,18 @@ package alert
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/smtp" "net/smtp"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
const alertHTTPTimeout = 10 * time.Second var alertClient = &http.Client{Timeout: 10 * time.Second}
var alertClient = &http.Client{Timeout: alertHTTPTimeout}
// sanitizeError strips the request URL from transport errors before they are
// stored or displayed. *url.Error embeds the full URL, which for several
// providers carries the credential itself (Telegram bot token in the path,
// webhook secrets in the URL). The operation and underlying cause — the useful
// diagnostic — are preserved.
func sanitizeError(err error) error {
if err == nil {
return nil
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
return fmt.Errorf("%s request failed: %w", urlErr.Op, urlErr.Err)
}
return err
}
type Provider interface { type Provider interface {
Send(ctx context.Context, title, message string) error Send(ctx context.Context, title, message string) error
@@ -65,7 +43,7 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
} }
resp, err := alertClient.Do(req) resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return sanitizeError(err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
@@ -132,24 +110,6 @@ func gotifyPayload(priority string) PayloadFunc {
} }
} }
func opsgeniePayload(priority string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]any{
"message": limitMessage(title, 130),
"description": message,
"source": "uptop",
"priority": priority,
})
}
}
func limitMessage(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
func GetProvider(cfg models.AlertConfig) Provider { func GetProvider(cfg models.AlertConfig) Provider {
switch cfg.Type { switch cfg.Type {
case "discord": case "discord":
@@ -213,20 +173,6 @@ func GetProvider(cfg models.AlertConfig) Provider {
Payload: gotifyPayload(priority), Payload: gotifyPayload(priority),
Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]}, Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]},
} }
case "opsgenie":
priority := "P3"
if p, ok := cfg.Settings["priority"]; ok && p != "" {
priority = p
}
apiURL := "https://api.opsgenie.com/v2/alerts"
if eu, ok := cfg.Settings["eu"]; ok && eu == "true" {
apiURL = "https://api.eu.opsgenie.com/v2/alerts"
}
return &HTTPProvider{
URL: apiURL,
Payload: opsgeniePayload(priority),
Headers: map[string]string{"Authorization": "GenieKey " + cfg.Settings["api_key"]},
}
default: default:
return nil return nil
} }
@@ -248,6 +194,7 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
return ctx.Err() return ctx.Err()
default: default:
} }
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
to := sanitizeHeader(e.To) to := sanitizeHeader(e.To)
from := sanitizeHeader(e.From) from := sanitizeHeader(e.From)
subject := sanitizeHeader(title) subject := sanitizeHeader(title)
@@ -259,67 +206,7 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
"Content-Type: text/plain; charset=utf-8\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" + "\r\n" +
body + "\r\n") body + "\r\n")
return sendMailContext(ctx, e.Host, e.Port, e.User, e.Pass, from, []string{to}, msg) return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
}
// sendMailContext is a ctx-aware replacement for smtp.SendMail.
// smtp.SendMail ignores context entirely — a blackholed SMTP server hangs for
// the OS TCP timeout (minutes). This dials with the context deadline and sets
// connection deadlines so cancellation is respected throughout.
func sendMailContext(ctx context.Context, host, port, user, pass, from string, rcpt []string, msg []byte) error {
addr := host + ":" + port
dialer := net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
if user != "" || pass != "" {
auth := smtp.PlainAuth("", user, pass, host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail: %w", err)
}
for _, r := range rcpt {
if err := c.Rcpt(r); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp data close: %w", err)
}
return c.Quit()
} }
type NtfyProvider struct { type NtfyProvider struct {
@@ -343,7 +230,7 @@ func (n *NtfyProvider) Send(ctx context.Context, title, message string) error {
} }
resp, err := alertClient.Do(req) resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return sanitizeError(err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
+2 -219
View File
@@ -1,20 +1,13 @@
package alert package alert
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings"
"testing" "testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
func TestHTTPProviderDiscord(t *testing.T) { func TestHTTPProviderDiscord(t *testing.T) {
@@ -203,76 +196,8 @@ func TestHTTPProviderGotify(t *testing.T) {
} }
} }
func TestHTTPProviderOpsgenie(t *testing.T) {
var received map[string]any
var authHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader = r.Header.Get("Authorization")
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(202)
}))
defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "test-genie-key",
"priority": "P1",
}})
hp := p.(*HTTPProvider)
hp.URL = srv.URL
if err := p.Send(context.Background(), "Site Down", "mysite.com is unreachable"); err != nil {
t.Fatalf("Send: %v", err)
}
if authHeader != "GenieKey test-genie-key" {
t.Errorf("expected auth 'GenieKey test-genie-key', got '%s'", authHeader)
}
if received["message"] != "Site Down" {
t.Errorf("unexpected message: %v", received["message"])
}
if received["description"] != "mysite.com is unreachable" {
t.Errorf("unexpected description: %v", received["description"])
}
if received["source"] != "uptop" {
t.Errorf("expected source 'uptop', got '%v'", received["source"])
}
if received["priority"] != "P1" {
t.Errorf("expected priority 'P1', got '%v'", received["priority"])
}
}
func TestOpsgenieEUEndpoint(t *testing.T) {
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "key", "eu": "true",
}})
hp := p.(*HTTPProvider)
if hp.URL != "https://api.eu.opsgenie.com/v2/alerts" {
t.Errorf("expected EU URL, got '%s'", hp.URL)
}
}
func TestOpsgenieUSEndpoint(t *testing.T) {
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "key",
}})
hp := p.(*HTTPProvider)
if hp.URL != "https://api.opsgenie.com/v2/alerts" {
t.Errorf("expected US URL, got '%s'", hp.URL)
}
}
func TestLimitMessage(t *testing.T) {
short := "short"
if got := limitMessage(short, 130); got != short {
t.Errorf("expected '%s', got '%s'", short, got)
}
long := string(make([]byte, 200))
if got := limitMessage(long, 130); len(got) != 130 {
t.Errorf("expected length 130, got %d", len(got))
}
}
func TestGetProviderNewTypes(t *testing.T) { func TestGetProviderNewTypes(t *testing.T) {
for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify", "opsgenie"} { for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} {
p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{ p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{
"token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost", "token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost",
}}) }})
@@ -305,145 +230,3 @@ func TestSanitizeHeader(t *testing.T) {
} }
} }
} }
// sanitizeError must strip the credential-bearing URL from a *url.Error while
// keeping the operation and underlying cause.
func TestSanitizeError(t *testing.T) {
urlErr := &url.Error{
Op: "Post",
URL: "https://api.telegram.org/bot123456:SECRET_TOKEN/sendMessage",
Err: errors.New("dial tcp: connection refused"),
}
got := sanitizeError(urlErr).Error()
for _, leak := range []string{"SECRET_TOKEN", "api.telegram.org", "sendMessage", "bot123456"} {
if strings.Contains(got, leak) {
t.Errorf("sanitized error leaked %q: %s", leak, got)
}
}
if !strings.Contains(got, "connection refused") {
t.Errorf("expected underlying cause preserved, got: %s", got)
}
// Non-url errors pass through unchanged.
plain := errors.New("plain failure")
if sanitizeError(plain).Error() != "plain failure" {
t.Errorf("non-url error altered: %s", sanitizeError(plain))
}
if sanitizeError(nil) != nil {
t.Error("nil should stay nil")
}
}
func TestEmailProvider_ContextTimeout(t *testing.T) {
// Listener that accepts but never speaks — simulates a blackholed SMTP server.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
// Hold connection open, never send banner.
go func(c net.Conn) {
time.Sleep(30 * time.Second)
c.Close()
}(conn)
}
}()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
provider := &EmailProvider{
Host: "127.0.0.1", Port: portStr,
From: "test@test.com", To: "dest@test.com",
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
err = provider.Send(ctx, "test", "body")
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error from stalled SMTP")
}
if elapsed > 2*time.Second {
t.Errorf("Send took %v — context deadline not respected", elapsed)
}
}
func TestSendMailContext_HappyPath(t *testing.T) {
// Minimal fake SMTP server that accepts one message.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
received := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprintf(conn, "220 localhost ESMTP\r\n")
scanner := bufio.NewScanner(conn)
var dataMode bool
var body strings.Builder
for scanner.Scan() {
line := scanner.Text()
if dataMode {
if line == "." {
dataMode = false
fmt.Fprintf(conn, "250 OK\r\n")
continue
}
body.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprintf(conn, "250-localhost\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL FROM"):
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "RCPT TO"):
fmt.Fprintf(conn, "250 OK\r\n")
case line == "DATA":
fmt.Fprintf(conn, "354 Go ahead\r\n")
dataMode = true
case line == "QUIT":
fmt.Fprintf(conn, "221 Bye\r\n")
received <- body.String()
return
default:
fmt.Fprintf(conn, "250 OK\r\n")
}
}
}()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = sendMailContext(ctx, "127.0.0.1", portStr, "", "", "from@test.com", []string{"to@test.com"}, []byte("Subject: test\r\n\r\nhello"))
if err != nil {
t.Fatalf("sendMailContext: %v", err)
}
select {
case body := <-received:
if !strings.Contains(body, "hello") {
t.Errorf("expected body to contain 'hello', got: %s", body)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for fake SMTP to receive message")
}
}
+6 -12
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
) )
type Config struct { type Config struct {
@@ -38,34 +38,28 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
// "probe" mode is handled directly in main.go before cluster.Start is called // "probe" mode is handled directly in main.go before cluster.Start is called
} }
const (
followerTimeout = 2 * time.Second
leaderFailureThreshold = 3
followerRetryInterval = 5 * time.Second
)
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) { func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
client := http.Client{Timeout: followerTimeout} client := http.Client{Timeout: 2 * time.Second}
failures := 0 failures := 0
threshold := leaderFailureThreshold threshold := 3
for { for {
select { select {
case <-time.After(followerRetryInterval): case <-time.After(5 * time.Second):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
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)
isLeaderHealthy := false isLeaderHealthy := false
if err == nil { if err == nil {
isLeaderHealthy = resp.StatusCode == http.StatusOK isLeaderHealthy = resp.StatusCode == 200
_ = resp.Body.Close() _ = resp.Body.Close()
} }
+61 -9
View File
@@ -10,15 +10,67 @@ import (
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store (minimal, for monitor.NewEngine) ---
type mockStore struct { type mockStore struct {
storetest.BaseMock sites []models.Site
} }
func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { return nil, nil }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) Close() error { return nil }
// --- Cluster Start Tests --- // --- Cluster Start Tests ---
func TestStart_LeaderMode(t *testing.T) { func TestStart_LeaderMode(t *testing.T) {
@@ -113,7 +165,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"))
@@ -203,7 +255,7 @@ func TestProbeRegister_Failure(t *testing.T) {
func TestProbeFetchAssignments_Success(t *testing.T) { func TestProbeFetchAssignments_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string][]models.Site{ json.NewEncoder(w).Encode(map[string][]models.Site{
"sites": {{SiteConfig: models.SiteConfig{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}}}, "sites": {{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}},
}) })
})) }))
defer srv.Close() defer srv.Close()
@@ -240,8 +292,8 @@ func TestProbeExecuteChecks(t *testing.T) {
defer srv.Close() defer srv.Close()
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}}, {ID: 1, Type: "http", URL: srv.URL},
{SiteConfig: models.SiteConfig{ID: 2, Type: "http", URL: srv.URL}}, {ID: 2, Type: "http", URL: srv.URL},
} }
strict := &http.Client{} strict := &http.Client{}
@@ -277,7 +329,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
var sites []models.Site var sites []models.Site
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
sites = append(sites, models.Site{SiteConfig: models.SiteConfig{ID: i + 1, Type: "http", URL: srv.URL}}) sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
} }
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true) results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
+15 -21
View File
@@ -6,14 +6,14 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log"
"net/http" "net/http"
"net/url" "net/url"
"sync" "sync"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
) )
type ProbeConfig struct { type ProbeConfig struct {
@@ -26,18 +26,12 @@ type ProbeConfig struct {
AllowPrivateTargets bool AllowPrivateTargets bool
} }
const (
probeMinInterval = 10
probeDefaultInterval = 30
probeAPITimeout = 10 * time.Second
)
func RunProbe(ctx context.Context, cfg ProbeConfig) error { func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if cfg.Interval < probeMinInterval { if cfg.Interval < 10 {
cfg.Interval = probeDefaultInterval cfg.Interval = 30
} }
apiClient := &http.Client{Timeout: probeAPITimeout} apiClient := &http.Client{Timeout: 10 * time.Second}
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets) dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
strictClient := &http.Client{ strictClient := &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@@ -53,7 +47,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
} }
if err := probeRegister(ctx, apiClient, cfg); err != nil { if err := probeRegister(ctx, apiClient, cfg); err != nil {
slog.Error("probe initial registration failed", "err", err) log.Printf("Probe: initial registration failed: %v (will retry)", err)
} }
for { for {
@@ -65,7 +59,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
sites, err := probeFetchAssignments(ctx, apiClient, cfg) sites, err := probeFetchAssignments(ctx, apiClient, cfg)
if err != nil { if err != nil {
slog.Error("probe failed to fetch assignments", "err", err) log.Printf("Probe: failed to fetch assignments: %v", err)
sleepCtx(ctx, 10*time.Second) sleepCtx(ctx, 10*time.Second)
continue continue
} }
@@ -79,7 +73,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if len(results) > 0 { if len(results) > 0 {
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil { if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
slog.Error("probe failed to report results", "err", err) log.Printf("Probe: failed to report results: %v", err)
} }
} }
@@ -96,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
@@ -114,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
@@ -158,12 +152,12 @@ loop:
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
cr := monitor.RunCheck(ctx, s.SiteConfig, strict, insecure, false, allowPrivate) cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
mu.Lock() mu.Lock()
results = append(results, probeResultItem{ results = append(results, probeResultItem{
SiteID: s.ID, SiteID: s.ID,
LatencyNs: cr.LatencyNs, LatencyNs: cr.LatencyNs,
IsUp: cr.Status == string(models.StatusUp), IsUp: cr.Status == "UP",
ErrorReason: cr.ErrorReason, ErrorReason: cr.ErrorReason,
}) })
mu.Unlock() mu.Unlock()
@@ -186,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
@@ -195,7 +189,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("results returned %d", resp.StatusCode) return fmt.Errorf("results returned %d", resp.StatusCode)
} }
slog.Info("probe reported check results", "count", len(results)) fmt.Printf("Probe: reported %d check results\n", len(results))
return nil return nil
} }
+21 -30
View File
@@ -1,13 +1,11 @@
package config package config
import ( import (
"context"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"reflect" "reflect"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
) )
type ApplyOpts struct { type ApplyOpts struct {
@@ -22,17 +20,17 @@ type Change struct {
Details string Details string
} }
func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Change, error) { func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
if err := Validate(f); err != nil { if err := Validate(f); err != nil {
return nil, err return nil, err
} }
existingAlerts, err := s.GetAllAlerts(ctx) existingAlerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return nil, fmt.Errorf("load alerts: %w", err) return nil, fmt.Errorf("load alerts: %w", err)
} }
existingSites, err := s.GetSites(ctx) existingSites, err := s.GetSites()
if err != nil { if err != nil {
return nil, fmt.Errorf("load sites: %w", err) return nil, fmt.Errorf("load sites: %w", err)
} }
@@ -42,7 +40,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
existingAlertsByName[a.Name] = a existingAlertsByName[a.Name] = a
} }
existingSitesByName := make(map[string]models.SiteConfig, len(existingSites)) existingSitesByName := make(map[string]models.Site, len(existingSites))
for _, s := range existingSites { for _, s := range existingSites {
existingSitesByName[s.Name] = s existingSitesByName[s.Name] = s
} }
@@ -54,7 +52,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
@@ -62,21 +59,18 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type}) changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type})
if !opts.DryRun { if !opts.DryRun {
id, err := s.AddAlertReturningID(ctx, a.Name, a.Type, a.Settings) id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings)
if err != nil { if err != nil {
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
if diff := diffAlert(existing, a); diff != "" { if diff := diffAlert(existing, a); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff})
if !opts.DryRun { if !opts.DryRun {
if err := s.UpdateAlert(ctx, existing.ID, a.Name, a.Type, a.Settings); err != nil { if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil {
return changes, fmt.Errorf("update alert %q: %w", a.Name, err) return changes, fmt.Errorf("update alert %q: %w", a.Name, err)
} }
} }
@@ -108,14 +102,11 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"}) changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"})
if !opts.DryRun { if !opts.DryRun {
id, err := s.AddSiteReturningID(ctx, site) id, err := s.AddSiteReturningID(site)
if err != nil { if err != nil {
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
@@ -123,7 +114,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if diff := diffSite(normalizeSite(existing), site); diff != "" { if diff := diffSite(normalizeSite(existing), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff})
if !opts.DryRun { if !opts.DryRun {
if err := s.UpdateSite(ctx, site); err != nil { if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update group %q: %w", g.Name, err) return changes, fmt.Errorf("update group %q: %w", g.Name, err)
} }
} }
@@ -134,7 +125,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
for _, g := range groups { for _, g := range groups {
parentID := groupMap[g.Name] parentID := groupMap[g.Name]
for _, child := range g.Monitors { for _, child := range g.Monitors {
c, err := applyMonitor(ctx, s, child, alertMap, existingSitesByName, parentID, opts.DryRun) c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
if err != nil { if err != nil {
return changes, err return changes, err
} }
@@ -143,7 +134,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
} }
for _, m := range topLevel { for _, m := range topLevel {
c, err := applyMonitor(ctx, s, m, alertMap, existingSitesByName, 0, opts.DryRun) c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun)
if err != nil { if err != nil {
return changes, err return changes, err
} }
@@ -164,7 +155,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
childDeletes = append(childDeletes, c) childDeletes = append(childDeletes, c)
} }
if !opts.DryRun { if !opts.DryRun {
if err := s.DeleteSite(ctx, es.ID); err != nil { if err := s.DeleteSite(es.ID); err != nil {
return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err) return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err)
} }
} }
@@ -178,7 +169,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
} }
changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type}) changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type})
if !opts.DryRun { if !opts.DryRun {
if err := s.DeleteAlert(ctx, ea.ID); err != nil { if err := s.DeleteAlert(ea.ID); err != nil {
return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err) return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err)
} }
} }
@@ -188,7 +179,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
return changes, nil return changes, nil
} }
func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.SiteConfig, parentID int, dryRun bool) ([]Change, error) { func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) {
alertID, err := resolveAlertID(alertMap, m.Alert) alertID, err := resolveAlertID(alertMap, m.Alert)
if err != nil { if err != nil {
return nil, fmt.Errorf("monitor %q: %w", m.Name, err) return nil, fmt.Errorf("monitor %q: %w", m.Name, err)
@@ -200,7 +191,7 @@ func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[st
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type}) changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type})
if !dryRun { if !dryRun {
if _, err := s.AddSiteReturningID(ctx, site); err != nil { if _, err := s.AddSiteReturningID(site); err != nil {
return changes, fmt.Errorf("create monitor %q: %w", m.Name, err) return changes, fmt.Errorf("create monitor %q: %w", m.Name, err)
} }
} }
@@ -209,7 +200,7 @@ func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[st
if diff := diffSite(normalizeSite(ex), site); diff != "" { if diff := diffSite(normalizeSite(ex), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff})
if !dryRun { if !dryRun {
if err := s.UpdateSite(ctx, site); err != nil { if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update monitor %q: %w", m.Name, err) return changes, fmt.Errorf("update monitor %q: %w", m.Name, err)
} }
} }
@@ -229,8 +220,8 @@ func resolveAlertID(alertMap map[string]int, name string) (int, error) {
return id, nil return id, nil
} }
func monitorToSite(m Monitor, alertID, parentID int) models.SiteConfig { func monitorToSite(m Monitor, alertID, parentID int) models.Site {
s := models.SiteConfig{ s := models.Site{
Name: m.Name, Name: m.Name,
Type: m.Type, Type: m.Type,
URL: m.URL, URL: m.URL,
@@ -276,7 +267,7 @@ func collectMonitorNames(monitors []Monitor, names map[string]bool) {
} }
} }
func normalizeSite(s models.SiteConfig) models.SiteConfig { func normalizeSite(s models.Site) models.Site {
if s.Method == "" { if s.Method == "" {
s.Method = "GET" s.Method = "GET"
} }
@@ -300,7 +291,7 @@ func diffAlert(existing models.AlertConfig, desired Alert) string {
return strings.Join(diffs, ", ") return strings.Join(diffs, ", ")
} }
func diffSite(existing, desired models.SiteConfig) string { func diffSite(existing, desired models.Site) string {
var diffs []string var diffs []string
if existing.URL != desired.URL { if existing.URL != desired.URL {
diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL)) diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL))
+28 -98
View File
@@ -1,12 +1,10 @@
package config package config
import ( import (
"context" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"strings" "strings"
"testing" "testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
) )
func newTestStore(t *testing.T) store.Store { func newTestStore(t *testing.T) store.Store {
@@ -15,7 +13,7 @@ func newTestStore(t *testing.T) store.Store {
if err != nil { if err != nil {
t.Fatalf("NewSQLiteStore: %v", err) t.Fatalf("NewSQLiteStore: %v", err)
} }
if err := s.Init(context.Background()); err != nil { if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err) t.Fatalf("Init: %v", err)
} }
return s return s
@@ -33,7 +31,7 @@ func TestApplyCreateFromScratch(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -48,12 +46,12 @@ func TestApplyCreateFromScratch(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", creates) t.Fatalf("expected 3 creates, got %d", creates)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 2 { if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites)) t.Fatalf("expected 2 sites, got %d", len(sites))
} }
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
if len(alerts) != 1 { if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts)) t.Fatalf("expected 1 alert, got %d", len(alerts))
} }
@@ -70,11 +68,11 @@ func TestApplyIdempotent(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err) t.Fatalf("first Apply: %v", err)
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("second Apply: %v", err) t.Fatalf("second Apply: %v", err)
} }
@@ -92,12 +90,12 @@ func TestApplyUpdate(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err) t.Fatalf("first Apply: %v", err)
} }
f.Monitors[0].Interval = 60 f.Monitors[0].Interval = 60
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("second Apply: %v", err) t.Fatalf("second Apply: %v", err)
} }
@@ -106,7 +104,7 @@ func TestApplyUpdate(t *testing.T) {
t.Fatalf("expected 1 update, got %+v", changes) t.Fatalf("expected 1 update, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if sites[0].Interval != 60 { if sites[0].Interval != 60 {
t.Fatalf("expected interval 60, got %d", sites[0].Interval) t.Fatalf("expected interval 60, got %d", sites[0].Interval)
} }
@@ -114,8 +112,8 @@ func TestApplyUpdate(t *testing.T) {
func TestApplyPrune(t *testing.T) { func TestApplyPrune(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddSite(context.Background(), models.SiteConfig{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f := &File{ f := &File{
Monitors: []Monitor{ Monitors: []Monitor{
@@ -123,7 +121,7 @@ func TestApplyPrune(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{Prune: true}) changes, err := Apply(s, f, ApplyOpts{Prune: true})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -138,7 +136,7 @@ func TestApplyPrune(t *testing.T) {
t.Fatalf("expected 1 delete, got %d", deleteCount) t.Fatalf("expected 1 delete, got %d", deleteCount)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 1 || sites[0].Name != "Keep" { if len(sites) != 1 || sites[0].Name != "Keep" {
t.Fatalf("expected only 'Keep', got %+v", sites) t.Fatalf("expected only 'Keep', got %+v", sites)
} }
@@ -152,7 +150,7 @@ func TestApplyDryRun(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true}) changes, err := Apply(s, f, ApplyOpts{DryRun: true})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -161,7 +159,7 @@ func TestApplyDryRun(t *testing.T) {
t.Fatalf("expected 1 create in dry-run, got %+v", changes) t.Fatalf("expected 1 create in dry-run, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 0 { if len(sites) != 0 {
t.Fatalf("expected 0 sites after dry-run, got %d", len(sites)) t.Fatalf("expected 0 sites after dry-run, got %d", len(sites))
} }
@@ -181,7 +179,7 @@ func TestApplyGroupHierarchy(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -190,8 +188,8 @@ func TestApplyGroupHierarchy(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", len(changes)) t.Fatalf("expected 3 creates, got %d", len(changes))
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
var group models.SiteConfig var group models.Site
for _, s := range sites { for _, s := range sites {
if s.Type == "group" { if s.Type == "group" {
group = s group = s
@@ -225,12 +223,12 @@ func TestApplyAlertReference(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
if sites[0].AlertID != alerts[0].ID { if sites[0].AlertID != alerts[0].ID {
t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID) t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID)
@@ -245,7 +243,7 @@ func TestApplyInvalidAlertRef(t *testing.T) {
}, },
} }
_, err := Apply(context.Background(), s, f, ApplyOpts{}) _, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "not found") { if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected alert not found error, got %v", err) t.Fatalf("expected alert not found error, got %v", err)
} }
@@ -260,83 +258,15 @@ func TestApplyDuplicateNames(t *testing.T) {
}, },
} }
_, err := Apply(context.Background(), s, f, ApplyOpts{}) _, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "duplicate") { if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate error, got %v", err) t.Fatalf("expected duplicate error, got %v", err)
} }
} }
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("Existing", "webhook", map[string]string{"url": "https://example.com"})
f := &File{ f := &File{
Monitors: []Monitor{ Monitors: []Monitor{
@@ -344,7 +274,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -353,7 +283,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
t.Fatalf("expected 1 create, got %+v", changes) t.Fatalf("expected 1 create, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if sites[0].AlertID == 0 { if sites[0].AlertID == 0 {
t.Fatal("expected non-zero alert_id for existing alert reference") t.Fatal("expected non-zero alert_id for existing alert reference")
} }
+9 -10
View File
@@ -1,24 +1,23 @@
package config package config
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"sort" "sort"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func Export(ctx context.Context, s store.Store) (*File, error) { func Export(s store.Store) (*File, error) {
dbAlerts, err := s.GetAllAlerts(ctx) dbAlerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return nil, fmt.Errorf("load alerts: %w", err) return nil, fmt.Errorf("load alerts: %w", err)
} }
dbSites, err := s.GetSites(ctx) dbSites, err := s.GetSites()
if err != nil { if err != nil {
return nil, fmt.Errorf("load sites: %w", err) return nil, fmt.Errorf("load sites: %w", err)
} }
@@ -34,9 +33,9 @@ func Export(ctx context.Context, s store.Store) (*File, error) {
}) })
} }
groups := make(map[int]models.SiteConfig) groups := make(map[int]models.Site)
children := make(map[int][]models.SiteConfig) children := make(map[int][]models.Site)
var topLevel []models.SiteConfig var topLevel []models.Site
for _, s := range dbSites { for _, s := range dbSites {
switch { switch {
@@ -76,7 +75,7 @@ func Export(ctx context.Context, s store.Store) (*File, error) {
return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil
} }
func siteToMonitor(s models.SiteConfig, alertIDToName map[int]string) Monitor { func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor {
m := Monitor{ m := Monitor{
Name: s.Name, Name: s.Name,
Type: s.Type, Type: s.Type,
+19 -21
View File
@@ -1,15 +1,13 @@
package config package config
import ( import (
"context" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"testing" "testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
func TestExportEmpty(t *testing.T) { func TestExportEmpty(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -20,11 +18,11 @@ func TestExportEmpty(t *testing.T) {
func TestExportAlertNames(t *testing.T) { func TestExportAlertNames(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
s.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -39,11 +37,11 @@ func TestExportAlertNames(t *testing.T) {
func TestExportGroupHierarchy(t *testing.T) { func TestExportGroupHierarchy(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
groupID, _ := s.AddSiteReturningID(context.Background(), models.SiteConfig{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -72,12 +70,12 @@ func TestExportGroupHierarchy(t *testing.T) {
func TestExportOmitsDefaults(t *testing.T) { func TestExportOmitsDefaults(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddSite(context.Background(), models.SiteConfig{ s.AddSite(models.Site{
Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, Name: "Web", URL: "https://example.com", Type: "http", Interval: 30,
Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7,
}) })
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -96,18 +94,18 @@ func TestExportOmitsDefaults(t *testing.T) {
func TestExportRoundTrip(t *testing.T) { func TestExportRoundTrip(t *testing.T) {
s1 := newTestStore(t) s1 := newTestStore(t)
s1.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s1.GetAllAlerts(context.Background()) alerts, _ := s1.GetAllAlerts()
s1.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s1.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s1.AddSite(context.Background(), models.SiteConfig{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s1.AddSite(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
exported, err := Export(context.Background(), s1) exported, err := Export(s1)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
s2 := newTestStore(t) s2 := newTestStore(t)
changes, err := Apply(context.Background(), s2, exported, ApplyOpts{}) changes, err := Apply(s2, exported, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -122,7 +120,7 @@ func TestExportRoundTrip(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", creates) t.Fatalf("expected 3 creates, got %d", creates)
} }
reexported, err := Export(context.Background(), s2) reexported, err := Export(s2)
if err != nil { if err != nil {
t.Fatalf("re-Export: %v", err) t.Fatalf("re-Export: %v", err)
} }
+4 -15
View File
@@ -1,14 +1,11 @@
package importer package importer
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"os" "os"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type KumaBackup struct { type KumaBackup struct {
@@ -83,7 +80,7 @@ func ConvertKuma(kb *KumaBackup) models.Backup {
} }
} }
var sites []models.SiteConfig var sites []models.Site
for _, m := range kb.MonitorList { for _, m := range kb.MonitorList {
site := convertKumaMonitor(m, kumaToUpkeepAlert) site := convertKumaMonitor(m, kumaToUpkeepAlert)
sites = append(sites, site) sites = append(sites, site)
@@ -135,8 +132,8 @@ func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConf
return result return result
} }
func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig { func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
site := models.SiteConfig{ site := models.Site{
ID: m.ID, ID: m.ID,
Name: m.Name, Name: m.Name,
Description: m.Description, Description: m.Description,
@@ -158,18 +155,10 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig {
site.DNSResolveType = m.DNSResolveType site.DNSResolveType = m.DNSResolveType
site.DNSServer = m.DNSResolveServer site.DNSServer = m.DNSResolveServer
site.Paused = !m.Active
switch m.Type { switch m.Type {
case "http": case "http":
site.URL = m.URL site.URL = m.URL
site.CheckSSL = m.ExpiryNotif site.CheckSSL = m.ExpiryNotif
case "push":
site.Type = "push"
b := make([]byte, 16)
if _, err := rand.Read(b); err == nil {
site.Token = hex.EncodeToString(b)
}
case "ping": case "ping":
if m.Hostname != "" { if m.Hostname != "" {
site.Hostname = m.Hostname site.Hostname = m.Hostname
-210
View File
@@ -1,210 +0,0 @@
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])
}
}
+3 -4
View File
@@ -2,12 +2,11 @@ package metrics
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
) )
func Handler(eng *monitor.Engine) http.HandlerFunc { func Handler(eng *monitor.Engine) http.HandlerFunc {
@@ -20,7 +19,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == models.StatusUp { if s.Status == "UP" {
val = 1 val = 1
} }
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
+55 -8
View File
@@ -8,23 +8,70 @@ import (
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
type mockStore struct { type mockStore struct {
storetest.BaseMock sites []models.Site
sites []models.SiteConfig
} }
func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { func (m *mockStore) Init() error { return nil }
return m.sites, nil func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
} }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) Close() error { return nil }
func TestMetricsHandler(t *testing.T) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
sites: []models.SiteConfig{ sites: []models.Site{
{ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30}, {ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30},
{ID: 2, Name: "DNS Check", Type: "dns", Interval: 60}, {ID: 2, Name: "DNS Check", Type: "dns", Interval: 60},
}, },
+3 -26
View File
@@ -2,7 +2,7 @@ package models
import "time" import "time"
type SiteConfig struct { type Site struct {
ID int ID int
Name string Name string
URL string URL string
@@ -26,11 +26,9 @@ type SiteConfig struct {
IgnoreTLS bool IgnoreTLS bool
Paused bool Paused bool
Regions string Regions string
}
type SiteState struct {
FailureCount int FailureCount int
Status Status Status string
StatusCode int StatusCode int
Latency time.Duration Latency time.Duration
CertExpiry time.Time CertExpiry time.Time
@@ -42,11 +40,6 @@ type SiteState struct {
LastSuccessAt time.Time LastSuccessAt time.Time
} }
type Site struct {
SiteConfig
SiteState
}
type StateChange struct { type StateChange struct {
ID int ID int
SiteID int SiteID int
@@ -86,22 +79,6 @@ type ProbeNode struct {
Version string Version string
} }
type LogEntry struct {
Message string
CreatedAt time.Time
}
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
// "last sent" / health indicators survive restarts instead of resetting to "never".
type AlertHealthRecord struct {
AlertID int
LastSendAt time.Time
LastSendOK bool
LastError string
SendCount int
FailCount int
}
type MaintenanceWindow struct { type MaintenanceWindow struct {
ID int ID int
MonitorID int MonitorID int
@@ -115,7 +92,7 @@ type MaintenanceWindow struct {
} }
type Backup struct { type Backup struct {
Sites []SiteConfig `json:"sites"` Sites []Site `json:"sites"`
Alerts []AlertConfig `json:"alerts"` Alerts []AlertConfig `json:"alerts"`
Users []User `json:"users"` Users []User `json:"users"`
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"` MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
-36
View File
@@ -1,36 +0,0 @@
package models
// safeAlertSettingKeys lists, per provider type, the alert settings that are
// NOT secret and may be shown or exported in the clear. Everything else is
// redacted. Providers absent from this map (discord, slack, webhook, pushover)
// carry their secret in a field a denylist would miss — the webhook URL, the
// pushover token/user — so all of their settings are redacted.
var safeAlertSettingKeys = map[string]map[string]bool{
"email": {"host": true, "port": true, "to": true, "from": true},
"ntfy": {"topic": true, "priority": true},
"telegram": {"chat_id": true},
"pagerduty": {"severity": true},
"gotify": {"priority": true},
"opsgenie": {"priority": true, "eu": true},
}
// RedactAlertSettings keeps only the known-safe keys for the alert type and
// redacts everything else. An allowlist fails safe: an unknown or newly added
// setting is redacted by default instead of leaking. Shared by the backup
// export path and the TUI alert detail panel so both render through the same
// policy.
func RedactAlertSettings(alertType string, settings map[string]string) map[string]string {
safe := safeAlertSettingKeys[alertType]
redacted := make(map[string]string, len(settings))
for k, v := range settings {
switch {
case v == "":
redacted[k] = ""
case safe[k]:
redacted[k] = v
default:
redacted[k] = "***REDACTED***"
}
}
return redacted
}
-18
View File
@@ -1,18 +0,0 @@
package models
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
StatusPending Status = "PENDING"
StatusLate Status = "LATE"
StatusStale Status = "STALE"
StatusSSLExp Status = "SSL EXP"
)
func (s Status) IsBroken() bool {
return s == StatusDown || s == StatusSSLExp
}
func (s Status) String() string { return string(s) }
-272
View File
@@ -1,272 +0,0 @@
package monitor
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type AlertHealth struct {
LastSendAt time.Time
LastSendOK bool
LastError string
SendCount int
FailCount int
}
// InitAlertHealth restores persisted alert send health so the dashboard shows real
// "last sent" / health state on startup instead of resetting every channel to "never".
func (e *Engine) InitAlertHealth(ctx context.Context) {
records, err := e.db.LoadAlertHealth(ctx)
if err != nil {
return
}
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
for id, r := range records {
e.alertHealth[id] = AlertHealth{
LastSendAt: r.LastSendAt,
LastSendOK: r.LastSendOK,
LastError: r.LastError,
SendCount: r.SendCount,
FailCount: r.FailCount,
}
}
}
// handleStatusChange folds a check result into the live state. snap is the
// stale snapshot the check ran against; the actual mutation is applied onto the
// CURRENT live entry via applyState, so a concurrent pause / config edit /
// heartbeat is never reverted by this write. Logs and alerts are emitted after
// the lock is released, off the critical section.
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
if !e.IsActive() {
return
}
inMaint := e.isInMaintenance(snap.ID)
status := models.Status(rawStatus)
var (
prev, next models.Status
name, typ string
alertID int
failCount, maxRetries int
confirmedDown bool
failedCheck bool
downSince time.Time
sslWarnFire bool
sslDays int
skipped bool
changed bool
)
_, exists := e.applyState(snap.ID, func(s *models.Site) {
// A non-UP result computed from a stale snapshot must not override a
// heartbeat (or newer check) that landed while we were evaluating.
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
skipped = true
return
}
prev = s.Status
name = s.Name
typ = s.Type
alertID = s.AlertID
maxRetries = s.MaxRetries
downSince = s.StatusChangedAt
// Fresh check results (measured by the run against snap).
s.StatusCode = code
s.Latency = snap.Latency
s.LastCheck = snap.LastCheck
s.HasSSL = snap.HasSSL
s.CertExpiry = snap.CertExpiry
s.LastError = errorReason
if status == models.StatusUp {
s.LastSuccessAt = time.Now()
s.LastError = ""
}
// Status + failure-count transition, based on the CURRENT live status.
if status == models.StatusUp {
s.FailureCount = 0
s.Status = models.StatusUp
} else {
if s.FailureCount <= s.MaxRetries {
s.FailureCount++
}
if s.FailureCount > s.MaxRetries {
if s.Status != status {
confirmedDown = true
}
s.Status = status
s.FailureCount = s.MaxRetries + 1
} else {
failedCheck = true
}
}
failCount = s.FailureCount
if s.Status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
s.StatusChangedAt = time.Now()
}
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
if typ == "http" && s.CheckSSL && s.HasSSL {
days := int(time.Until(s.CertExpiry).Hours() / 24)
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
sslWarnFire = true
sslDays = days
s.SentSSLWarning = true
} else if days > s.ExpiryThreshold {
s.SentSSLWarning = false
}
}
next = s.Status
changed = next != prev
})
if !exists || skipped {
return
}
e.recordCheck(snap.ID, latency, status == models.StatusUp)
if confirmedDown {
if errorReason != "" {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
}
} else if failedCheck {
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
}
if changed && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
}
if sslWarnFire {
if !inMaint {
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
} else {
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
}
}
if prev == models.StatusUp && next == models.StatusLate {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
}
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
} else {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
if errorReason != "" {
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
}
if typ == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
}
e.triggerAlert(alertID, "🚨 ALERT", msg)
}
}
if prev.IsBroken() && next == models.StatusUp {
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
if !inMaint {
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
}
}
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
}
}
func (e *Engine) triggerAlert(alertID int, title, message string) {
if alertID <= 0 {
return
}
cfg, err := e.db.GetAlert(e.ctx, alertID)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return
}
provider := alert.GetProvider(cfg)
if provider != nil {
go func() {
ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout)
defer cancel()
if err := provider.Send(ctx, title, message); err != nil {
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
e.recordAlertResult(alertID, false, err.Error())
} else {
e.recordAlertResult(alertID, true, "")
}
}()
}
}
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
h := e.alertHealth[alertID]
h.LastSendAt = time.Now()
h.LastSendOK = ok
h.SendCount++
if ok {
h.LastError = ""
} else {
h.LastError = errMsg
h.FailCount++
}
e.alertHealth[alertID] = h
// Persist so health survives restarts; DB IO off the alert path.
e.enqueueWrite(writeAlertHealth{rec: models.AlertHealthRecord{
AlertID: alertID,
LastSendAt: h.LastSendAt,
LastSendOK: h.LastSendOK,
LastError: h.LastError,
SendCount: h.SendCount,
FailCount: h.FailCount,
}})
}
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
e.alertHealthMu.RLock()
defer e.alertHealthMu.RUnlock()
return e.alertHealth[alertID]
}
func (e *Engine) TestAlert(ctx context.Context, alertID int) error {
cfg, err := e.db.GetAlert(ctx, alertID)
if err != nil {
return fmt.Errorf("failed to load alert: %w", err)
}
provider := alert.GetProvider(cfg)
if provider == nil {
return fmt.Errorf("no provider for type %q", cfg.Type)
}
ctx, cancel := context.WithTimeout(ctx, alertSendTimeout)
defer cancel()
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
if err != nil {
e.recordAlertResult(alertID, false, err.Error())
return err
}
e.recordAlertResult(alertID, true, "")
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
return nil
}
-487
View File
@@ -1,487 +0,0 @@
package monitor
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// --- Group 1: State Machine ---
func TestHandleStatusChange_PendingToUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert for PENDING→UP")
}
}
func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (under retry threshold), got %s", s.Status)
}
if s.FailureCount != 1 {
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
}
}
func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 2},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
if s.FailureCount != 3 {
t.Errorf("expected FailureCount 3, got %d", s.FailureCount)
}
waitAsync()
calls := ms.getAlertCallsSnapshot()
if len(calls) == 0 || calls[0] != 1 {
t.Errorf("expected alert call for alertID 1, got %v", calls)
}
}
func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected alert on immediate DOWN")
}
}
func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 4},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected recovery alert")
}
}
func TestHandleStatusChange_DownStaysDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no re-alert for already DOWN")
}
}
func TestHandleStatusChange_SSLExpired(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
s, _ := getSite(e, 1)
if s.Status != "SSL EXP" {
t.Errorf("expected SSL EXP, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected alert on SSL EXP")
}
}
func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance")
}
logs := e.GetLogs()
found := false
for _, l := range logs {
if containsStr(l.Message, "suppressed") {
found = true
break
}
}
if !found {
t.Error("expected log mentioning suppressed")
}
}
func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance recovery")
}
}
func TestHandleStatusChange_SSLWarning(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if !s.SentSSLWarning {
t.Error("expected SentSSLWarning=true")
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected SSL warning alert")
}
}
func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no repeat SSL warning")
}
}
func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)},
}
injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if s.SentSSLWarning {
t.Error("expected SentSSLWarning reset to false")
}
}
func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
ms := newMockStore()
ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "")
s, _ := getSite(e, 1)
if !s.SentSSLWarning {
t.Error("expected SentSSLWarning=true even in maintenance")
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) != 0 {
t.Error("expected no alert during maintenance")
}
}
func TestHandleStatusChange_InactiveEngine(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.SetActive(false)
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Error("expected no state change when inactive")
}
}
// --- Group 10: liveState merge (lost-update race) ---
// A pause that lands while a check is in flight must survive the check's
// write-back. The old code snapshotted the site, ran the check, then wrote the
// whole stale struct back — reverting the pause.
func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
// `site` is the stale snapshot the check ran against (Paused=false).
// Meanwhile the user pauses the monitor.
e.ToggleSitePause(1)
// Check completes and folds its result in using the stale snapshot.
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
s, _ := getSite(e, 1)
if !s.Paused {
t.Error("pause was reverted by a stale check write-back")
}
if s.Status != "DOWN" {
t.Errorf("expected check result still applied (DOWN), got %s", s.Status)
}
}
// A config edit that lands while a check is in flight must survive; the check
// must not resurrect the old config from its snapshot.
func TestHandleStatusChange_ConfigEditDuringCheckSurvives(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com", Type: "http", MaxRetries: 0, Interval: 30},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
// Config changes mid-check.
e.UpdateSiteConfig(models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60})
// Stale check (ran against http://old.com) folds its result in.
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
s, _ := getSite(e, 1)
if s.URL != "http://new.com" {
t.Errorf("config edit reverted: URL=%s", s.URL)
}
if s.Interval != 60 {
t.Errorf("config edit reverted: Interval=%d", s.Interval)
}
}
// The classic push false-DOWN: a heartbeat marks the monitor UP while a
// staleness evaluation (computed from the older LastCheck) is mid-flight.
// The stale DOWN must not overwrite the fresh heartbeat.
func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
// Snapshot the engine would have taken before evaluating staleness:
// LastCheck is old, so checkPush decided "DOWN".
snap := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Token: "tok", Interval: 10},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
}
injectSite(e, snap)
// A heartbeat lands first, advancing LastCheck and confirming UP.
if !e.RecordHeartbeat("tok") {
t.Fatal("heartbeat rejected")
}
// Now the in-flight stale evaluation tries to write DOWN.
e.handleStatusChange(snap, "DOWN", 0, 0, "heartbeat missed")
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("stale DOWN overwrote a fresh heartbeat: status=%s", s.Status)
}
}
// A check result for a site removed mid-check must be dropped, not recreate it.
func TestHandleStatusChange_RemovedSiteDropped(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.RemoveSite(1)
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
if _, ok := getSite(e, 1); ok {
t.Error("removed site was recreated by a late check write-back")
}
}
// --- Group 12: Phase 3 engine correctness ---
// PENDING→DOWN must honor MaxRetries instead of alerting on first failure.
func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "new-monitor", MaxRetries: 2},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "timeout")
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING during retry, got %s", s.Status)
}
if s.FailureCount != 1 {
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
}
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
s, _ = getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING during retry 2, got %s", s.Status)
}
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
s, _ = getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
}
}
// LATE→DOWN must also honor MaxRetries.
func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-mon", MaxRetries: 1},
SiteState: models.SiteState{Status: "LATE"},
}
injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "missed heartbeat")
s, _ := getSite(e, 1)
if s.Status != "LATE" {
t.Errorf("expected LATE during retry, got %s", s.Status)
}
e.handleStatusChange(s, "DOWN", 0, 0, "missed heartbeat")
s, _ = getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
}
}
+41 -81
View File
@@ -3,29 +3,18 @@ package monitor
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"github.com/miekg/dns" "github.com/miekg/dns"
probing "github.com/prometheus-community/pro-bing" probing "github.com/prometheus-community/pro-bing"
) )
const (
maxErrorLength = 256
defaultAcceptedCodes = "200-299"
defaultHTTPStatusMin = 200
defaultHTTPStatusMax = 300
defaultTimeout = 5 * time.Second
defaultDNSServer = "1.1.1.1"
defaultDNSPort = "53"
)
type CheckResult struct { type CheckResult struct {
SiteID int SiteID int
Status string // "UP", "DOWN", "SSL EXP" Status string // "UP", "DOWN", "SSL EXP"
@@ -36,57 +25,52 @@ type CheckResult struct {
ErrorReason string ErrorReason string
} }
func RunCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure, allowPrivate bool) CheckResult { func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
// Resolve + validate once for non-HTTP types to prevent DNS-rebind TOCTOU: private := len(allowPrivate) > 0 && allowPrivate[0]
// a second resolve in the check function could return a different (private) IP.
// HTTP is safe — SafeDialContext resolves and validates at dial time. if site.Type != "http" && site.Type != "dns" && !private {
var pinnedIP net.IP
if site.Type != "http" && site.Type != "dns" && !allowPrivate {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
} }
if host != "" { if host != "" {
ips, err := net.LookupIP(host) if ips, err := net.LookupIP(host); err == nil {
if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "resolve failed: " + err.Error()}
}
for _, ip := range ips { for _, ip := range ips {
if isPrivateIP(ip) { if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "target resolves to private IP"} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"}
}
} }
} }
pinnedIP = ips[0]
} }
} }
switch site.Type { switch site.Type {
case "http": case "http":
return runHTTPCheck(ctx, site, strict, insecure, globalInsecure) return runHTTPCheck(site, strict, insecure, globalInsecure)
case "ping": case "ping":
return runPingCheck(ctx, site, pinnedIP) return runPingCheck(site)
case "port": case "port":
return runPortCheck(ctx, site, pinnedIP) return runPortCheck(site)
case "dns": case "dns":
return runDNSCheck(ctx, site, allowPrivate) return runDNSCheck(site)
default: default:
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "unsupported monitor type: " + site.Type} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type}
} }
} }
func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure bool) CheckResult { func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
method := site.Method method := site.Method
if method == "" { if method == "" {
method = "GET" method = "GET"
} }
timeout := siteTimeout(site) timeout := siteTimeout(site)
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "invalid request: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()}
} }
client := strict client := strict
@@ -100,26 +84,23 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
result := CheckResult{ result := CheckResult{
SiteID: site.ID, SiteID: site.ID,
Status: string(models.StatusUp), Status: "UP",
LatencyNs: latency.Nanoseconds(), LatencyNs: latency.Nanoseconds(),
} }
if err != nil { if err != nil {
result.Status = string(models.StatusDown) result.Status = "DOWN"
result.ErrorReason = truncateError(err.Error(), maxErrorLength) result.ErrorReason = truncateError(err.Error(), 256)
return result return result
} }
defer func() { defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
result.StatusCode = resp.StatusCode result.StatusCode = resp.StatusCode
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
result.Status = string(models.StatusDown) result.Status = "DOWN"
expected := site.AcceptedCodes expected := site.AcceptedCodes
if expected == "" { if expected == "" {
expected = defaultAcceptedCodes expected = "200-299"
} }
result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected) result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected)
} }
@@ -129,7 +110,7 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
cert := resp.TLS.PeerCertificates[0] cert := resp.TLS.PeerCertificates[0]
result.CertExpiry = cert.NotAfter result.CertExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) { if time.Now().After(cert.NotAfter) {
result.Status = string(models.StatusSSLExp) result.Status = "SSL EXP"
result.ErrorReason = "SSL certificate expired" result.ErrorReason = "SSL certificate expired"
} }
} }
@@ -137,7 +118,7 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
return result return result
} }
func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult { func runPingCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
@@ -145,10 +126,7 @@ func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
pinger, err := probing.NewPinger(host) pinger, err := probing.NewPinger(host)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "ping setup: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()}
}
if pinnedIP != nil {
pinger.SetIPAddr(&net.IPAddr{IP: pinnedIP})
} }
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = siteTimeout(site) pinger.Timeout = siteTimeout(site)
@@ -159,24 +137,21 @@ func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
} }
if pinger.Statistics().PacketsRecv == 0 { if pinger.Statistics().PacketsRecv == 0 {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
} }
stats := pinger.Statistics() stats := pinger.Statistics()
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: stats.AvgRtt.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()}
} }
func runPortCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult { func runPortCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
} }
if pinnedIP != nil {
host = pinnedIP.String()
}
addr := net.JoinHostPort(host, strconv.Itoa(site.Port)) addr := net.JoinHostPort(host, strconv.Itoa(site.Port))
timeout := siteTimeout(site) timeout := siteTimeout(site)
@@ -185,13 +160,13 @@ func runPortCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), 256)}
} }
_ = conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) CheckResult { func runDNSCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
@@ -199,26 +174,11 @@ func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) C
server := site.DNSServer server := site.DNSServer
if server == "" { if server == "" {
server = defaultDNSServer server = "1.1.1.1"
} }
serverHost, serverPort, err := net.SplitHostPort(server) if _, _, err := net.SplitHostPort(server); err != nil {
if err != nil { server = net.JoinHostPort(server, "53")
serverHost = server
serverPort = defaultDNSPort
} }
if !allowPrivate {
if serverPort != defaultDNSPort {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "DNS server port must be 53"}
}
if ips, err := net.LookupIP(serverHost); err == nil {
for _, ip := range ips {
if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "DNS server resolves to private address"}
}
}
}
}
server = net.JoinHostPort(serverHost, serverPort)
qtype := dns.TypeA qtype := dns.TypeA
switch site.DNSResolveType { switch site.DNSResolveType {
@@ -251,24 +211,24 @@ func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) C
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
} }
if r.Rcode != dns.RcodeSuccess { if r.Rcode != dns.RcodeSuccess {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]} return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]}
} }
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
func siteTimeout(site models.SiteConfig) time.Duration { func siteTimeout(site models.Site) time.Duration {
if site.Timeout > 0 { if site.Timeout > 0 {
return time.Duration(site.Timeout) * time.Second return time.Duration(site.Timeout) * time.Second
} }
return defaultTimeout return 5 * time.Second
} }
func isCodeAccepted(code int, accepted string) bool { func isCodeAccepted(code int, accepted string) bool {
if accepted == "" { if accepted == "" {
return code >= defaultHTTPStatusMin && code < defaultHTTPStatusMax return code >= 200 && code < 300
} }
for _, part := range strings.Split(accepted, ",") { for _, part := range strings.Split(accepted, ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
+29 -97
View File
@@ -1,7 +1,6 @@
package monitor package monitor
import ( import (
"context"
"crypto/tls" "crypto/tls"
"net" "net"
"net/http" "net/http"
@@ -10,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
func TestRunCheck_HTTP_Success(t *testing.T) { func TestRunCheck_HTTP_Success(t *testing.T) {
@@ -19,8 +18,8 @@ func TestRunCheck_HTTP_Success(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -39,8 +38,8 @@ func TestRunCheck_HTTP_ServerError(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status) t.Errorf("expected DOWN, got %s", result.Status)
@@ -60,8 +59,8 @@ func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}} }}
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"} site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"}
result := RunCheck(context.Background(), site, client, client, false, false) result := RunCheck(site, client, client, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP with accepted 200-399, got %s", result.Status) t.Errorf("expected UP with accepted 200-399, got %s", result.Status)
@@ -76,8 +75,8 @@ func TestRunCheck_HTTP_MethodRespected(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"} site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"}
RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if receivedMethod != "HEAD" { if receivedMethod != "HEAD" {
t.Errorf("expected HEAD, got %s", receivedMethod) t.Errorf("expected HEAD, got %s", receivedMethod)
@@ -91,8 +90,8 @@ func TestRunCheck_HTTP_Timeout(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Timeout: 1} site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN on timeout, got %s", result.Status) t.Errorf("expected DOWN on timeout, got %s", result.Status)
@@ -109,8 +108,8 @@ func TestRunCheck_HTTP_SSLFields(t *testing.T) {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
} }
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true} site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true}
result := RunCheck(context.Background(), site, http.DefaultClient, insecureClient, false, false) result := RunCheck(site, http.DefaultClient, insecureClient, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -130,17 +129,11 @@ func TestRunCheck_Port_Open(t *testing.T) {
} }
defer ln.Close() defer ln.Close()
_, portStr, err := net.SplitHostPort(ln.Addr().String()) _, portStr, _ := net.SplitHostPort(ln.Addr().String())
if err != nil { port, _ := strconv.Atoi(portStr)
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, true) result := RunCheck(site, nil, nil, false, true)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -155,73 +148,18 @@ func TestRunCheck_Port_Closed(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, portStr, err := net.SplitHostPort(ln.Addr().String()) _, portStr, _ := net.SplitHostPort(ln.Addr().String())
if err != nil { port, _ := strconv.Atoi(portStr)
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
ln.Close() ln.Close()
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
result := RunCheck(context.Background(), site, nil, nil, false, true) result := RunCheck(site, nil, nil, false, true)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status) t.Errorf("expected DOWN, got %s", result.Status)
} }
} }
func TestRunPortCheck_UsesPinnedIP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
// Pass a pinned IP — runPortCheck should dial it instead of resolving Hostname.
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "will-not-resolve.invalid", Port: port, Timeout: 2}
result := runPortCheck(context.Background(), site, net.ParseIP("127.0.0.1"))
if result.Status != "UP" {
t.Errorf("expected UP when pinned IP used, got %s: %s", result.Status, result.ErrorReason)
}
}
func TestRunPortCheck_NilPinnedIP_UsesHostname(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, portStr, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := runPortCheck(context.Background(), site, nil)
if result.Status != "UP" {
t.Errorf("expected UP with nil pinnedIP fallback, got %s: %s", result.Status, result.ErrorReason)
}
}
func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) { func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -229,17 +167,11 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
} }
defer ln.Close() defer ln.Close()
_, portStr, err := net.SplitHostPort(ln.Addr().String()) _, portStr, _ := net.SplitHostPort(ln.Addr().String())
if err != nil { port, _ := strconv.Atoi(portStr)
t.Fatalf("SplitHostPort: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("Atoi: %v", err)
}
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, false) result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN when private targets blocked, got %s", result.Status) t.Errorf("expected DOWN when private targets blocked, got %s", result.Status)
@@ -247,8 +179,8 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
} }
func TestRunCheck_UnknownType(t *testing.T) { func TestRunCheck_UnknownType(t *testing.T) {
site := models.SiteConfig{ID: 1, Type: "invalid"} site := models.Site{ID: 1, Type: "invalid"}
result := RunCheck(context.Background(), site, nil, nil, false, false) result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN for unknown type, got %s", result.Status) t.Errorf("expected DOWN for unknown type, got %s", result.Status)
@@ -281,10 +213,10 @@ func TestIsCodeAccepted(t *testing.T) {
} }
func TestSiteTimeout(t *testing.T) { func TestSiteTimeout(t *testing.T) {
if got := siteTimeout(models.SiteConfig{Timeout: 0}); got != 5*time.Second { if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second {
t.Errorf("expected 5s default, got %v", got) t.Errorf("expected 5s default, got %v", got)
} }
if got := siteTimeout(models.SiteConfig{Timeout: 10}); got != 10*time.Second { if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second {
t.Errorf("expected 10s, got %v", got) t.Errorf("expected 10s, got %v", got)
} }
} }
-329
View File
@@ -1,329 +0,0 @@
package monitor
import (
"context"
"fmt"
"math/rand/v2"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (e *Engine) RecordHeartbeat(token string) bool {
if !e.IsActive() {
return false
}
e.mu.RLock()
targetID, ok := e.tokenIndex[token]
e.mu.RUnlock()
if !ok {
return false
}
var (
prevStatus models.Status
name string
alertID int
downSince time.Time
)
_, exists := e.applyState(targetID, func(s *models.Site) {
prevStatus = s.Status
name = s.Name
alertID = s.AlertID
downSince = s.StatusChangedAt // captured before mutation = when it went down
s.LastCheck = time.Now()
s.Status = models.StatusUp
s.FailureCount = 0
s.Latency = 0
s.LastError = ""
s.LastSuccessAt = time.Now()
if prevStatus != models.StatusUp {
s.StatusChangedAt = time.Now()
}
})
if !exists {
return false
}
switch prevStatus {
case models.StatusPending:
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
case models.StatusLate:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
case models.StatusStale:
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
case models.StatusDown:
downDur := ""
if !downSince.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
}
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
}
e.recordCheck(targetID, 0, true)
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
}
return true
}
func (e *Engine) getRecheckChan(id int) chan struct{} {
e.recheckMu.Lock()
defer e.recheckMu.Unlock()
ch, ok := e.recheck[id]
if !ok {
ch = make(chan struct{}, 1)
e.recheck[id] = ch
}
return ch
}
func (e *Engine) signalRecheck(id int) {
ch := e.getRecheckChan(id)
select {
case ch <- struct{}{}:
default:
}
}
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
recheckCh := e.getRecheckChan(id)
// Stagger initial check to avoid thundering herd on startup
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(stagger):
case <-ctx.Done():
return
}
e.checkByID(ctx, id)
for {
select {
case <-ctx.Done():
return
default:
}
if !e.IsActive() {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists {
return
}
if site.Paused {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
case <-recheckCh:
}
continue
}
interval := site.Interval
if interval < minCheckInterval {
interval = minCheckInterval
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done():
return
case <-recheckCh:
}
e.checkByID(ctx, id)
}
}
func (e *Engine) checkByID(ctx context.Context, id int) {
if !e.IsActive() {
return
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists || site.Paused {
return
}
switch site.Type {
case "push":
e.checkPush(ctx, site)
case "group":
e.checkGroup(ctx, site)
default:
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
updatedSite.Latency = time.Duration(result.LatencyNs)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
}
}
func (e *Engine) checkPush(_ context.Context, site models.Site) {
if site.Status == models.StatusPending {
return
}
interval := time.Duration(site.Interval) * time.Second
grace := interval / 2
if grace < minPushGrace {
grace = minPushGrace
}
overdue := site.LastCheck.Add(interval)
staleMark := overdue.Add(grace / 2)
graceEnd := overdue.Add(grace)
now := time.Now()
if now.After(graceEnd) {
if site.Status != models.StatusDown {
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
}
} else if now.After(staleMark) {
if site.Status != models.StatusStale {
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
}
} else if now.After(overdue) {
if site.Status != models.StatusLate {
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
}
}
}
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
e.mu.RLock()
status := models.StatusUp
hasChildren := false
for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
}
hasChildren = true
if child.Paused || e.isInMaintenance(child.ID) {
continue
}
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
status = models.StatusDown
} else if child.Status == models.StatusStale && status != models.StatusDown {
status = models.StatusStale
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
status = models.StatusLate
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
status = models.StatusPending
}
}
e.mu.RUnlock()
if !hasChildren {
status = models.StatusPending
}
var prev models.Status
e.applyState(site.ID, func(s *models.Site) {
prev = s.Status
s.Status = status
if status != prev && prev != models.StatusPending {
s.StatusChangedAt = time.Now()
}
})
e.recordCheck(site.ID, 0, !status.IsBroken())
if status != prev && prev != models.StatusPending {
e.enqueueWrite(writeStateChange{siteID: site.ID, fromStatus: string(prev), toStatus: string(status)})
if status.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' is %s", site.Name, status))
} else if prev.IsBroken() {
e.AddLog(fmt.Sprintf("Group '%s' recovered", site.Name))
}
}
}
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
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) {
e.aggStrategy = strategy
}
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
e.mu.RLock()
site, exists := e.liveState[siteID]
e.mu.RUnlock()
if !exists {
return
}
staleAfter := time.Duration(site.Interval) * time.Second * 3
if staleAfter < time.Minute {
staleAfter = time.Minute
}
now := time.Now()
e.probeResultsMu.Lock()
if e.probeResults[siteID] == nil {
e.probeResults[siteID] = make(map[string]NodeResult)
}
e.probeResults[siteID][nodeID] = NodeResult{
NodeID: nodeID,
IsUp: isUp,
LatencyNs: latencyNs,
CheckedAt: now,
ErrorReason: errorReason,
}
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
for id, r := range e.probeResults[siteID] {
if now.Sub(r.CheckedAt) > staleAfter {
delete(e.probeResults[siteID], id)
continue
}
results = append(results, r)
}
e.probeResultsMu.Unlock()
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
probeStatus := models.StatusUp
if !aggUp {
probeStatus = models.StatusDown
}
updatedSite := site
updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
}
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
src := e.probeResults[siteID]
cp := make(map[string]NodeResult, len(src))
for k, v := range src {
cp[k] = v
}
return cp
}
-389
View File
@@ -1,389 +0,0 @@
package monitor
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// --- Group 2: Heartbeat ---
func TestRecordHeartbeat_ValidToken(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
if !e.RecordHeartbeat("abc123") {
t.Error("expected true for valid token")
}
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if time.Since(s.LastCheck) > time.Second {
t.Error("expected LastCheck to be recent")
}
}
func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) {
ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site)
if !e.RecordHeartbeat("abc123") {
t.Error("expected true")
}
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
if s.FailureCount != 0 {
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
}
waitAsync()
if len(ms.getAlertCallsSnapshot()) == 0 {
t.Error("expected recovery alert")
}
}
func TestRecordHeartbeat_UnknownToken(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
if e.RecordHeartbeat("unknown") {
t.Error("expected false for unknown token")
}
}
func TestRecordHeartbeat_InactiveEngine(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.SetActive(false)
if e.RecordHeartbeat("abc123") {
t.Error("expected false when inactive")
}
}
// --- Group 3: Push Deadline ---
func TestCheckPush_DeadlineMissed(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected DOWN after missed deadline, got %s", s.Status)
}
}
func TestCheckPush_OverdueBecomesLate(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "LATE" {
t.Errorf("expected LATE when overdue but within grace, got %s", s.Status)
}
}
func TestCheckPush_OverdueBecomesStale(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
// interval=300, grace=150 (300/2), staleMark=overdue+75
// at 380s: past staleMark(375) but before graceEnd(450)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-380 * time.Second)},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "STALE" {
t.Errorf("expected STALE when past midpoint of grace, got %s", s.Status)
}
}
func TestCheckPush_WithinDeadline(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP, got %s", s.Status)
}
}
func TestCheckPush_PendingStaysPending(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site)
e.checkPush(context.Background(), site)
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING to stay until first heartbeat, got %s", s.Status)
}
}
// --- Group 4: Group Checks ---
func TestCheckGroup_AllChildrenUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "PENDING"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected group UP, got %s", s.Status)
}
}
func TestCheckGroup_OneChildDown(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "DOWN" {
t.Errorf("expected group DOWN, got %s", s.Status)
}
}
func TestCheckGroup_PausedChildIgnored(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (paused child ignored), got %s", s.Status)
}
}
func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
ms := newMockStore()
ms.maintenance[3] = true
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.refreshMaintenanceCache(context.Background())
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "UP" {
t.Errorf("expected UP (maint child ignored), got %s", s.Status)
}
}
func TestCheckGroup_NoChildren(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Status != "PENDING" {
t.Errorf("expected PENDING for no children, got %s", s.Status)
}
}
// Groups must not auto-pause when all children are paused — that creates a
// one-way trap because monitorRoutine skips paused sites.
func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child1 := models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "UP"},
}
child2 := models.Site{
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child1)
injectSite(e, child2)
e.checkGroup(context.Background(), group)
s, _ := getSite(e, 1)
if s.Paused {
t.Error("group must not auto-pause when all children are paused")
}
}
// Dead probe results must be expired so they don't poison aggregation.
func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", Interval: 30},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.probeResultsMu.Lock()
e.probeResults[1] = map[string]NodeResult{
"dead-probe": {
NodeID: "dead-probe",
IsUp: false,
CheckedAt: time.Now().Add(-10 * time.Minute),
},
}
e.probeResultsMu.Unlock()
e.IngestProbeResult("live-probe", 1, 5000, true, "")
e.probeResultsMu.RLock()
_, deadExists := e.probeResults[1]["dead-probe"]
_, liveExists := e.probeResults[1]["live-probe"]
e.probeResultsMu.RUnlock()
if deadExists {
t.Error("stale probe result should have been expired")
}
if !liveExists {
t.Error("live probe result should still exist")
}
}
// RemoveSite must clean up probeResults.
func TestRemoveSite_CleansProbeResults(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.probeResultsMu.Lock()
e.probeResults[1] = map[string]NodeResult{
"node-a": {NodeID: "node-a", IsUp: true, CheckedAt: time.Now()},
}
e.probeResultsMu.Unlock()
e.RemoveSite(1)
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
if _, exists := e.probeResults[1]; exists {
t.Error("probe results should be cleaned up after RemoveSite")
}
}
-64
View File
@@ -1,64 +0,0 @@
package monitor
import (
"context"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
)
// dbWrite is a single unit of deferred persistence. The engine enqueues these
// onto a buffered channel; a single writer goroutine drains and executes them,
// serializing all writes through one connection and surfacing errors instead of
// discarding them. desc names the write for diagnostics on drop/failure.
type dbWrite interface {
exec(ctx context.Context, s store.Store) error
desc() string
}
type writeLog struct{ message string }
func (w writeLog) exec(ctx context.Context, s store.Store) error { return s.SaveLog(ctx, w.message) }
func (w writeLog) desc() string { return "log" }
type writeCheck struct {
siteID int
latencyNs int64
isUp bool
}
func (w writeCheck) exec(ctx context.Context, s store.Store) error {
return s.SaveCheck(ctx, w.siteID, w.latencyNs, w.isUp)
}
func (w writeCheck) desc() string { return "check" }
type writeStateChange struct {
siteID int
fromStatus string
toStatus string
reason string
}
func (w writeStateChange) exec(ctx context.Context, s store.Store) error {
return s.SaveStateChange(ctx, w.siteID, w.fromStatus, w.toStatus, w.reason)
}
func (w writeStateChange) desc() string { return "state-change" }
type writeAlertHealth struct{ rec models.AlertHealthRecord }
func (w writeAlertHealth) exec(ctx context.Context, s store.Store) error {
return s.SaveAlertHealth(ctx, w.rec)
}
func (w writeAlertHealth) desc() string { return "alert-health" }
type writeProbeCheck struct {
siteID int
nodeID string
latencyNs int64
isUp bool
}
func (w writeProbeCheck) exec(ctx context.Context, s store.Store) error {
return s.SaveCheckFromNode(ctx, w.siteID, w.nodeID, w.latencyNs, w.isUp)
}
func (w writeProbeCheck) desc() string { return "probe-check" }
+4 -7
View File
@@ -1,9 +1,6 @@
package monitor package monitor
import ( import "time"
"context"
"time"
)
const maxHistoryLen = 60 const maxHistoryLen = 60
@@ -14,8 +11,8 @@ type SiteHistory struct {
UpChecks int UpChecks int
} }
func (e *Engine) InitHistory(ctx context.Context) { func (e *Engine) InitHistory() {
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen) all, err := e.db.LoadAllHistory(maxHistoryLen)
if err != nil { if err != nil {
e.AddLog("Failed to load check history: " + err.Error()) e.AddLog("Failed to load check history: " + err.Error())
return return
@@ -64,7 +61,7 @@ func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) {
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
} }
e.enqueueWrite(writeCheck{siteID: siteID, latencyNs: latency.Nanoseconds(), isUp: isUp}) go func() { _ = e.db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }()
} }
func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) { func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) {
-70
View File
@@ -1,70 +0,0 @@
package monitor
import (
"context"
"fmt"
"time"
)
func (e *Engine) maintenancePruner(ctx context.Context) {
ticker := time.NewTicker(maintPruneInterval)
defer ticker.Stop()
e.pruneMaintenanceWindows(ctx)
for {
select {
case <-ticker.C:
e.pruneMaintenanceWindows(ctx)
case <-ctx.Done():
return
}
}
}
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
if err != nil {
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
return
}
if pruned > 0 {
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
}
}
func (e *Engine) isInMaintenance(monitorID int) bool {
e.maintCacheMu.RLock()
defer e.maintCacheMu.RUnlock()
return e.maintCache[monitorID]
}
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
if err != nil {
return
}
directMaint := make(map[int]bool)
var globalMaint bool
for _, w := range windows {
if w.MonitorID == 0 {
globalMaint = true
} else {
directMaint[w.MonitorID] = true
}
}
resolved := make(map[int]bool)
e.mu.RLock()
for id, site := range e.liveState {
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
resolved[id] = true
}
}
e.mu.RUnlock()
e.maintCacheMu.Lock()
e.maintCache = resolved
e.maintCacheMu.Unlock()
}
-53
View File
@@ -1,53 +0,0 @@
package monitor
import (
"context"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// Maintenance cache resolves parent relationships correctly.
func TestIsInMaintenance_UsesCache(t *testing.T) {
ms := newMockStore()
ms.maintenance[10] = true // direct maintenance on group
e := newTestEngine(ms)
group := models.Site{
SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
child := models.Site{
SiteConfig: models.SiteConfig{ID: 20, Name: "child", Type: "http", ParentID: 10},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group)
injectSite(e, child)
e.refreshMaintenanceCache(context.Background())
if !e.isInMaintenance(10) {
t.Error("group should be in maintenance (direct)")
}
if !e.isInMaintenance(20) {
t.Error("child should be in maintenance (parent)")
}
if e.isInMaintenance(99) {
t.Error("unknown monitor should not be in maintenance")
}
}
// Global maintenance (monitor_id=0) applies to all monitors.
func TestIsInMaintenance_GlobalMaintenance(t *testing.T) {
ms := newMockStore()
ms.maintenance[0] = true
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
if !e.isInMaintenance(1) {
t.Error("all monitors should be in maintenance during global window")
}
}
+585 -184
View File
@@ -4,14 +4,16 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"math/rand/v2"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/alert"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
) )
const ( const (
@@ -19,19 +21,22 @@ const (
pollInterval = 5 * time.Second pollInterval = 5 * time.Second
minCheckInterval = 5 minCheckInterval = 5
minPushGrace = 60 * time.Second minPushGrace = 60 * time.Second
maintPruneInterval = 15 * time.Minute
defaultMaintRetention = 7 * 24 * time.Hour
dbWriteBuffer = 4096
alertSendTimeout = 30 * time.Second
dbPruneInterval = 10 * time.Minute
) )
type AlertHealth struct {
LastSendAt time.Time
LastSendOK bool
LastError string
SendCount int
FailCount int
}
type Engine struct { type Engine struct {
mu sync.RWMutex mu sync.RWMutex
liveState map[int]models.Site liveState map[int]models.Site
logMu sync.RWMutex logMu sync.RWMutex
logStore []models.LogEntry logStore []string
activeMu sync.RWMutex activeMu sync.RWMutex
isActive bool isActive bool
@@ -48,25 +53,11 @@ type Engine struct {
alertHealthMu sync.RWMutex alertHealthMu sync.RWMutex
alertHealth map[int]AlertHealth alertHealth map[int]AlertHealth
recheckMu sync.RWMutex
recheck map[int]chan struct{}
maintCacheMu sync.RWMutex
maintCache map[int]bool
db store.Store db store.Store
insecureSkipVerify bool insecureSkipVerify bool
allowPrivateTargets bool allowPrivateTargets bool
maintRetention time.Duration
strictClient *http.Client strictClient *http.Client
insecureClient *http.Client insecureClient *http.Client
dbWrites chan dbWrite
writerWG sync.WaitGroup
checkerWG sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
stopOnce sync.Once
} }
func NewEngine(s store.Store) *Engine { func NewEngine(s store.Store) *Engine {
@@ -83,15 +74,11 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
liveState: make(map[int]models.Site), liveState: make(map[int]models.Site),
histories: make(map[int]*SiteHistory), histories: make(map[int]*SiteHistory),
tokenIndex: make(map[string]int), tokenIndex: make(map[string]int),
recheck: make(map[int]chan struct{}),
probeResults: make(map[int]map[string]NodeResult), probeResults: make(map[int]map[string]NodeResult),
alertHealth: make(map[int]AlertHealth), alertHealth: make(map[int]AlertHealth),
aggStrategy: AggAnyDown, aggStrategy: AggAnyDown,
isActive: true, isActive: true,
allowPrivateTargets: allowPrivateTargets, allowPrivateTargets: allowPrivateTargets,
maintRetention: defaultMaintRetention,
dbWrites: make(chan dbWrite, dbWriteBuffer),
ctx: context.Background(),
db: s, db: s,
strictClient: &http.Client{ strictClient: &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@@ -108,18 +95,10 @@ 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) {
e.maintRetention = d
}
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
func sanitizeLog(s string) string { func sanitizeLog(s string) string {
@@ -142,140 +121,148 @@ func fmtDurationShort(d time.Duration) string {
return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24) return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24)
} }
// appendLog adds a timestamped entry to the in-memory ring buffer and returns func (e *Engine) AddLog(msg string) {
// it. It never touches the database, so it is safe to call from the db-write
// drop/error path without recursing back through the write queue.
func (e *Engine) appendLog(msg string) models.LogEntry {
entry := models.LogEntry{
Message: sanitizeLog(msg),
CreatedAt: time.Now(),
}
e.logMu.Lock() e.logMu.Lock()
e.logStore = append([]models.LogEntry{entry}, e.logStore...) defer e.logMu.Unlock()
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
e.logStore = append([]string{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries { if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries] e.logStore = e.logStore[:maxLogEntries]
} }
e.logMu.Unlock() go func() { _ = e.db.SaveLog(entry) }()
return entry
} }
func (e *Engine) AddLog(msg string) { func (e *Engine) InitLogs() {
entry := e.appendLog(msg) logs, err := e.db.LoadLogs(100)
e.enqueueWrite(writeLog{message: entry.Message})
}
// enqueueWrite hands a persistence task to the writer goroutine without
// blocking the caller. If the queue is saturated the write is dropped and noted
// in the in-memory log only (never re-enqueued, to avoid recursion via AddLog).
func (e *Engine) enqueueWrite(w dbWrite) {
select {
case e.dbWrites <- w:
default:
e.appendLog(fmt.Sprintf("db write queue full, dropped %s", w.desc()))
}
}
// dbWriter is the single goroutine that owns all writes. Serializing writes
// through one path removes the fire-and-forget goroutine pile-up, surfaces
// errors, and lets retention run on a timer instead of per-insert. It drains
// any buffered writes on shutdown before returning.
func (e *Engine) dbWriter(ctx context.Context) {
defer e.writerWG.Done()
pruneTicker := time.NewTicker(dbPruneInterval)
defer pruneTicker.Stop()
e.prune(ctx)
for {
select {
case w := <-e.dbWrites:
if err := w.exec(ctx, e.db); err != nil {
e.appendLog(fmt.Sprintf("db %s write failed: %v", w.desc(), err))
}
case <-pruneTicker.C:
e.prune(ctx)
case <-ctx.Done():
e.drainWrites()
return
}
}
}
// drainWrites flushes everything still buffered, best-effort, at shutdown.
// Uses context.Background because the engine ctx is already cancelled when
// this runs — writes still need to reach the DB.
func (e *Engine) drainWrites() {
for {
select {
case w := <-e.dbWrites:
if err := w.exec(context.Background(), e.db); err != nil {
e.appendLog(fmt.Sprintf("db %s write failed (drain): %v", w.desc(), err))
}
default:
return
}
}
}
func (e *Engine) prune(ctx context.Context) {
if err := e.db.PruneLogs(ctx); err != nil {
e.appendLog(fmt.Sprintf("log prune failed: %v", err))
}
if err := e.db.PruneCheckHistory(ctx); err != nil {
e.appendLog(fmt.Sprintf("check-history prune failed: %v", err))
}
if err := e.db.PruneStateChanges(ctx); err != nil {
e.appendLog(fmt.Sprintf("state-change prune failed: %v", err))
}
}
// Stop signals the writer goroutine to drain and exit, then blocks until it
// has. Call it before closing the store so no write races a closed DB.
func (e *Engine) Stop() {
e.stopOnce.Do(func() {
if e.cancel != nil {
e.cancel()
}
e.checkerWG.Wait()
e.writerWG.Wait()
e.drainWrites()
})
}
func (e *Engine) InitLogs(ctx context.Context) {
entries, err := e.db.LoadLogs(ctx, maxLogEntries)
if err != nil { if err != nil {
return return
} }
if len(entries) == 0 { if len(logs) == 0 {
return return
} }
e.logMu.Lock() e.logMu.Lock()
defer e.logMu.Unlock() defer e.logMu.Unlock()
e.logStore = entries e.logStore = logs
} }
func (e *Engine) GetLogs() []models.LogEntry { func (e *Engine) GetLogs() []string {
e.logMu.RLock() e.logMu.RLock()
defer e.logMu.RUnlock() defer e.logMu.RUnlock()
logs := make([]models.LogEntry, len(e.logStore)) logs := make([]string, len(e.logStore))
copy(logs, e.logStore) copy(logs, e.logStore)
return logs return logs
} }
func (e *Engine) SetActive(active bool) {
e.activeMu.Lock()
defer e.activeMu.Unlock()
if e.isActive != active {
e.isActive = active
status := "RESUMED (Active)"
if !active {
status = "PAUSED (Passive)"
}
e.AddLog(fmt.Sprintf("Engine %s", status))
}
}
func (e *Engine) IsActive() bool {
e.activeMu.RLock()
defer e.activeMu.RUnlock()
return e.isActive
}
func (e *Engine) GetAllSites() []models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
sites := make([]models.Site, 0, len(e.liveState))
for _, s := range e.liveState {
sites = append(sites, s)
}
return sites
}
func (e *Engine) GetLiveState() map[int]models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
cp := make(map[int]models.Site, len(e.liveState))
for k, v := range e.liveState {
cp[k] = v
}
return cp
}
func (e *Engine) RecordHeartbeat(token string) bool {
if !e.IsActive() {
return false
}
e.mu.Lock()
defer e.mu.Unlock()
targetID, ok := e.tokenIndex[token]
if !ok {
return false
}
site, exists := e.liveState[targetID]
if !exists {
return false
}
prevStatus := site.Status
site.LastCheck = time.Now()
site.Status = "UP"
site.FailureCount = 0
site.Latency = 0
site.LastError = ""
site.LastSuccessAt = time.Now()
if prevStatus != "UP" {
site.StatusChangedAt = time.Now()
}
e.liveState[targetID] = site
switch prevStatus {
case "PENDING":
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", site.Name))
case "LATE":
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name))
case "DOWN":
downDur := ""
if !site.StatusChangedAt.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
}
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", site.Name, downDur))
go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", site.Name, downDur))
}
if prevStatus != "UP" && prevStatus != "PENDING" {
go func() { _ = e.db.SaveStateChange(targetID, prevStatus, "UP", "") }()
}
return true
}
func (e *Engine) addToTokenIndex(site models.Site) {
if site.Type == "push" && site.Token != "" {
e.tokenIndex[site.Token] = site.ID
}
}
func (e *Engine) removeFromTokenIndex(id int) {
for token, sid := range e.tokenIndex {
if sid == id {
delete(e.tokenIndex, token)
return
}
}
}
func (e *Engine) Start(ctx context.Context) { func (e *Engine) Start(ctx context.Context) {
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
// trace the cross-method call, and cancelling the parent reaps this child
// regardless, so the leak it warns about can't occur.
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
e.ctx = ctx
e.writerWG.Add(1)
go e.dbWriter(ctx)
e.checkerWG.Add(1)
go func() { go func() {
defer e.checkerWG.Done()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -283,7 +270,7 @@ func (e *Engine) Start(ctx context.Context) {
default: default:
} }
configs, err := e.db.GetSites(ctx) sites, err := e.db.GetSites()
if err != nil { if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err)) e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select { select {
@@ -293,55 +280,30 @@ func (e *Engine) Start(ctx context.Context) {
} }
continue continue
} }
dbIDs := make(map[int]bool, len(configs)) for _, s := range sites {
for _, cfg := range configs {
dbIDs[cfg.ID] = true
e.mu.RLock() e.mu.RLock()
existing, exists := e.liveState[cfg.ID] _, exists := e.liveState[s.ID]
e.mu.RUnlock() e.mu.RUnlock()
if !exists { if !exists {
e.mu.Lock() e.mu.Lock()
site := models.Site{SiteConfig: cfg, SiteState: models.SiteState{Status: models.StatusPending}} s.Status = "PENDING"
if h, ok := e.GetHistory(cfg.ID); ok && len(h.Statuses) > 0 { if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
if h.Statuses[len(h.Statuses)-1] { if h.Statuses[len(h.Statuses)-1] {
site.Status = models.StatusUp s.Status = "UP"
} else { } else {
site.Status = models.StatusDown s.Status = "DOWN"
} }
if len(h.Latencies) > 0 { if len(h.Latencies) > 0 {
site.Latency = h.Latencies[len(h.Latencies)-1] s.Latency = h.Latencies[len(h.Latencies)-1]
} }
} }
e.liveState[cfg.ID] = site e.liveState[s.ID] = s
e.addToTokenIndex(site) e.addToTokenIndex(s)
e.mu.Unlock() e.mu.Unlock()
e.checkerWG.Add(1) go e.monitorRoutine(ctx, s.ID)
go func(id int) {
defer e.checkerWG.Done()
e.monitorRoutine(ctx, id)
}(cfg.ID)
} else if existing.SiteConfig != cfg {
e.UpdateSiteConfig(cfg)
} }
} }
// Refresh after sites load so the cache covers newly added sites.
// On first iteration liveState was empty before the loop above.
e.refreshMaintenanceCache(ctx)
e.mu.RLock()
var vanished []int
for id := range e.liveState {
if !dbIDs[id] {
vanished = append(vanished, id)
}
}
e.mu.RUnlock()
for _, id := range vanished {
e.RemoveSite(id)
e.AddLog(fmt.Sprintf("Monitor removed (no longer in DB): ID %d", id))
}
select { select {
case <-time.After(pollInterval): case <-time.After(pollInterval):
case <-ctx.Done(): case <-ctx.Done():
@@ -349,10 +311,449 @@ func (e *Engine) Start(ctx context.Context) {
} }
} }
}() }()
}
e.checkerWG.Add(1) func (e *Engine) UpdateSiteConfig(site models.Site) {
e.mu.Lock()
defer e.mu.Unlock()
if existing, ok := e.liveState[site.ID]; ok {
e.removeFromTokenIndex(site.ID)
site.Status = existing.Status
site.StatusCode = existing.StatusCode
site.Latency = existing.Latency
site.CertExpiry = existing.CertExpiry
site.HasSSL = existing.HasSSL
site.LastCheck = existing.LastCheck
site.SentSSLWarning = existing.SentSSLWarning
site.FailureCount = existing.FailureCount
site.LastError = existing.LastError
site.StatusChangedAt = existing.StatusChangedAt
site.LastSuccessAt = existing.LastSuccessAt
e.liveState[site.ID] = site
e.addToTokenIndex(site)
}
}
func (e *Engine) RemoveSite(id int) {
e.mu.Lock()
e.removeFromTokenIndex(id)
delete(e.liveState, id)
e.mu.Unlock()
e.removeHistory(id)
}
func (e *Engine) ToggleSitePause(id int) bool {
e.mu.Lock()
defer e.mu.Unlock()
site, ok := e.liveState[id]
if !ok {
return false
}
site.Paused = !site.Paused
e.liveState[id] = site
if site.Paused {
e.AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name))
}
return site.Paused
}
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
// Stagger initial check to avoid thundering herd on startup
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(stagger):
case <-ctx.Done():
return
}
e.checkByID(id)
for {
select {
case <-ctx.Done():
return
default:
}
if !e.IsActive() {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
continue
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists {
return
}
if site.Paused {
select {
case <-time.After(pollInterval):
case <-ctx.Done():
return
}
continue
}
interval := site.Interval
if interval < minCheckInterval {
interval = minCheckInterval
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done():
return
}
e.checkByID(id)
}
}
func (e *Engine) checkByID(id int) {
if !e.IsActive() {
return
}
e.mu.RLock()
site, exists := e.liveState[id]
e.mu.RUnlock()
if !exists || site.Paused {
return
}
switch site.Type {
case "push":
e.checkPush(site)
case "group":
e.checkGroup(site)
default:
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
updatedSite.Latency = time.Duration(result.LatencyNs)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
}
}
func (e *Engine) checkPush(site models.Site) {
if site.Status == "PENDING" {
return
}
interval := time.Duration(site.Interval) * time.Second
grace := interval / 2
if grace < minPushGrace {
grace = minPushGrace
}
overdue := site.LastCheck.Add(interval)
graceEnd := overdue.Add(grace)
now := time.Now()
if now.After(graceEnd) {
if site.Status != "DOWN" {
e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed")
}
} else if now.After(overdue) {
if site.Status != "LATE" {
e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue")
}
}
}
func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
if !e.IsActive() {
return
}
newState := site
newState.StatusCode = code
newState.LastError = errorReason
if rawStatus == "UP" {
newState.LastSuccessAt = time.Now()
newState.LastError = ""
} else {
newState.LastSuccessAt = site.LastSuccessAt
}
if site.Status == "UP" && rawStatus != "UP" {
newState.FailureCount++
if newState.FailureCount > site.MaxRetries {
newState.Status = rawStatus
newState.FailureCount = site.MaxRetries + 1
if errorReason != "" {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", site.Name, errorReason))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name))
}
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries))
}
} else if rawStatus == "UP" {
newState.FailureCount = 0
newState.Status = "UP"
} else {
newState.Status = rawStatus
newState.FailureCount = site.MaxRetries + 1
}
if newState.Status != site.Status && site.Status != "PENDING" {
newState.StatusChangedAt = time.Now()
} else if site.StatusChangedAt.IsZero() && newState.Status != "PENDING" {
newState.StatusChangedAt = time.Now()
} else {
newState.StatusChangedAt = site.StatusChangedAt
}
inMaint := e.isInMaintenance(site.ID)
if site.Type == "http" && site.CheckSSL && site.HasSSL {
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
if !inMaint {
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
} else {
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", site.Name))
}
newState.SentSSLWarning = true
} else if daysLeft > site.ExpiryThreshold {
newState.SentSSLWarning = false
}
}
e.mu.Lock()
if _, ok := e.liveState[site.ID]; ok {
e.liveState[site.ID] = newState
}
e.mu.Unlock()
e.recordCheck(site.ID, latency, rawStatus == "UP")
if newState.Status != site.Status && site.Status != "PENDING" {
go func() { _ = e.db.SaveStateChange(site.ID, site.Status, newState.Status, errorReason) }()
}
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
if site.Status == "UP" && newState.Status == "LATE" {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", site.Name))
}
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
} else {
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
if errorReason != "" {
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", site.Name, errorReason)
}
if site.Type == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
}
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
}
}
if isBroken(site.Status) && newState.Status == "UP" {
downDur := ""
if !site.StatusChangedAt.IsZero() {
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
}
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", site.Name, downDur))
if !inMaint {
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", site.Name, downDur))
}
}
if site.Status == "LATE" && newState.Status == "UP" && !isBroken(site.Status) {
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", site.Name))
}
}
func (e *Engine) triggerAlert(alertID int, title, message string) {
cfg, err := e.db.GetAlert(alertID)
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return
}
provider := alert.GetProvider(cfg)
if provider != nil {
go func() { go func() {
defer e.checkerWG.Done() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
e.maintenancePruner(ctx) defer cancel()
if err := provider.Send(ctx, title, message); err != nil {
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
e.recordAlertResult(alertID, false, err.Error())
} else {
e.recordAlertResult(alertID, true, "")
}
}() }()
} }
}
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
e.alertHealthMu.Lock()
defer e.alertHealthMu.Unlock()
h := e.alertHealth[alertID]
h.LastSendAt = time.Now()
h.LastSendOK = ok
h.SendCount++
if ok {
h.LastError = ""
} else {
h.LastError = errMsg
h.FailCount++
}
e.alertHealth[alertID] = h
}
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
e.alertHealthMu.RLock()
defer e.alertHealthMu.RUnlock()
return e.alertHealth[alertID]
}
func (e *Engine) TestAlert(alertID int) error {
cfg, err := e.db.GetAlert(alertID)
if err != nil {
return fmt.Errorf("failed to load alert: %w", err)
}
provider := alert.GetProvider(cfg)
if provider == nil {
return fmt.Errorf("no provider for type %q", cfg.Type)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
if err != nil {
e.recordAlertResult(alertID, false, err.Error())
return err
}
e.recordAlertResult(alertID, true, "")
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
return nil
}
func (e *Engine) isInMaintenance(monitorID int) bool {
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
if err != nil {
return false
}
return inMaint
}
func (e *Engine) GetDisplayStatus(site models.Site) string {
if site.Paused {
return "PAUSED"
}
if e.isInMaintenance(site.ID) {
return "MAINT"
}
return site.Status
}
func (e *Engine) checkGroup(site models.Site) {
e.mu.RLock()
status := "UP"
hasChildren := false
allPaused := true
for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
}
hasChildren = true
if !child.Paused {
allPaused = false
}
if child.Paused || e.isInMaintenance(child.ID) {
continue
}
if child.Status == "DOWN" || child.Status == "SSL EXP" {
status = "DOWN"
} else if child.Status == "PENDING" && status != "DOWN" {
status = "PENDING"
}
}
e.mu.RUnlock()
if !hasChildren {
status = "PENDING"
}
e.mu.Lock()
s := e.liveState[site.ID]
s.Status = status
if hasChildren && allPaused {
s.Paused = true
}
e.liveState[site.ID] = s
e.mu.Unlock()
}
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
e.aggStrategy = strategy
}
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
e.probeResultsMu.Lock()
if e.probeResults[siteID] == nil {
e.probeResults[siteID] = make(map[string]NodeResult)
}
e.probeResults[siteID][nodeID] = NodeResult{
NodeID: nodeID,
IsUp: isUp,
LatencyNs: latencyNs,
CheckedAt: time.Now(),
ErrorReason: errorReason,
}
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
for _, r := range e.probeResults[siteID] {
results = append(results, r)
}
e.probeResultsMu.Unlock()
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
e.mu.RLock()
site, exists := e.liveState[siteID]
e.mu.RUnlock()
if !exists {
return
}
rawStatus := "UP"
if !aggUp {
rawStatus = "DOWN"
}
updatedSite := site
updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency), errorReason)
}
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
e.probeResultsMu.RLock()
defer e.probeResultsMu.RUnlock()
src := e.probeResults[siteID]
cp := make(map[string]NodeResult, len(src))
for k, v := range src {
cp[k] = v
}
return cp
}
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(siteID, limit)
if err != nil {
return nil
}
return changes
}
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -7,17 +7,13 @@ import (
"time" "time"
) )
const dialTimeout = 10 * time.Second
var privateRanges []*net.IPNet var privateRanges []*net.IPNet
func init() { func init() {
cidrs := []string{ cidrs := []string{
"0.0.0.0/8",
"127.0.0.0/8", "127.0.0.0/8",
"::1/128", "::1/128",
"10.0.0.0/8", "10.0.0.0/8",
"100.64.0.0/10",
"172.16.0.0/12", "172.16.0.0/12",
"192.168.0.0/16", "192.168.0.0/16",
"169.254.0.0/16", "169.254.0.0/16",
@@ -31,9 +27,6 @@ func init() {
} }
func isPrivateIP(ip net.IP) bool { func isPrivateIP(ip net.IP) bool {
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLoopback() {
return true
}
for _, network := range privateRanges { for _, network := range privateRanges {
if network.Contains(ip) { if network.Contains(ip) {
return true return true
@@ -62,7 +55,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
} }
} }
dialer := &net.Dialer{Timeout: dialTimeout} dialer := &net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips { for _, ip := range ips {
target := net.JoinHostPort(ip.IP.String(), port) target := net.JoinHostPort(ip.IP.String(), port)
conn, err := dialer.DialContext(ctx, network, target) conn, err := dialer.DialContext(ctx, network, target)
-158
View File
@@ -1,158 +0,0 @@
package monitor
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (e *Engine) SetActive(active bool) {
e.activeMu.Lock()
defer e.activeMu.Unlock()
if e.isActive != active {
e.isActive = active
status := "RESUMED (Active)"
if !active {
status = "PAUSED (Passive)"
}
e.AddLog(fmt.Sprintf("Engine %s", status))
}
}
func (e *Engine) IsActive() bool {
e.activeMu.RLock()
defer e.activeMu.RUnlock()
return e.isActive
}
func (e *Engine) GetAllSites() []models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
sites := make([]models.Site, 0, len(e.liveState))
for _, s := range e.liveState {
sites = append(sites, s)
}
return sites
}
func (e *Engine) GetLiveState() map[int]models.Site {
e.mu.RLock()
defer e.mu.RUnlock()
cp := make(map[int]models.Site, len(e.liveState))
for k, v := range e.liveState {
cp[k] = v
}
return cp
}
func (e *Engine) addToTokenIndex(site models.Site) {
if site.Type == "push" && site.Token != "" {
e.tokenIndex[site.Token] = site.ID
}
}
func (e *Engine) removeFromTokenIndex(id int) {
for token, sid := range e.tokenIndex {
if sid == id {
delete(e.tokenIndex, token)
return
}
}
}
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
e.mu.Lock()
if existing, ok := e.liveState[cfg.ID]; ok {
e.removeFromTokenIndex(cfg.ID)
existing.SiteConfig = cfg
e.liveState[cfg.ID] = existing
e.addToTokenIndex(existing)
}
e.mu.Unlock()
e.signalRecheck(cfg.ID)
}
func (e *Engine) RemoveSite(id int) {
e.mu.Lock()
e.removeFromTokenIndex(id)
delete(e.liveState, id)
e.mu.Unlock()
e.removeHistory(id)
e.probeResultsMu.Lock()
delete(e.probeResults, id)
e.probeResultsMu.Unlock()
e.recheckMu.Lock()
delete(e.recheck, id)
e.recheckMu.Unlock()
}
func (e *Engine) ToggleSitePause(id int) bool {
var (
paused bool
name string
)
_, ok := e.applyState(id, func(s *models.Site) {
s.Paused = !s.Paused
paused = s.Paused
name = s.Name
})
if !ok {
return false
}
if paused {
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
}
return paused
}
// applyState atomically reads, mutates, and writes back the live entry for id.
// The mutator runs under the engine write lock and receives a pointer to the
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
// never clobbered by a stale snapshot. The mutator must only touch runtime /
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
// are owned by UpdateSiteConfig and must not be written here. Returns the
// post-mutation copy and whether the site still exists.
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
e.mu.Lock()
defer e.mu.Unlock()
cur, ok := e.liveState[id]
if !ok {
return models.Site{}, false
}
mutate(&cur)
e.liveState[id] = cur
return cur, true
}
func (e *Engine) GetDisplayStatus(site models.Site) string {
if site.Paused {
return "PAUSED"
}
if e.isInMaintenance(site.ID) {
return "MAINT"
}
return string(site.Status)
}
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
if err != nil {
return nil
}
return changes
}
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
if err != nil {
return nil
}
return changes
}
-133
View File
@@ -1,133 +0,0 @@
package monitor
import (
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com"},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond},
}
injectSite(e, site)
updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60}
e.UpdateSiteConfig(updated)
s, _ := getSite(e, 1)
if s.URL != "http://new.com" {
t.Errorf("expected URL updated, got %s", s.URL)
}
if s.Status != "DOWN" {
t.Errorf("expected Status preserved, got %s", s.Status)
}
if s.FailureCount != 3 {
t.Errorf("expected FailureCount preserved, got %d", s.FailureCount)
}
if s.Latency != 100*time.Millisecond {
t.Errorf("expected Latency preserved, got %v", s.Latency)
}
}
func TestRemoveSite_CleansUp(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
e.recordCheck(1, 5*time.Millisecond, true)
e.RemoveSite(1)
if _, ok := getSite(e, 1); ok {
t.Error("expected site removed from liveState")
}
if e.RecordHeartbeat("tok1") {
t.Error("expected token removed from index")
}
if _, ok := e.GetHistory(1); ok {
t.Error("expected history removed")
}
}
func TestToggleSitePause(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site)
paused := e.ToggleSitePause(1)
if !paused {
t.Error("expected paused=true after first toggle")
}
s, _ := getSite(e, 1)
if !s.Paused {
t.Error("expected Paused=true in state")
}
paused = e.ToggleSitePause(1)
if paused {
t.Error("expected paused=false after second toggle")
}
}
func TestToggleSitePause_NonexistentSite(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
if e.ToggleSitePause(999) {
t.Error("expected false for nonexistent site")
}
}
func TestGetAllSites_ReturnsCopy(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
SiteState: models.SiteState{Status: "UP"},
})
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "s2"},
SiteState: models.SiteState{Status: "DOWN"},
})
sites := e.GetAllSites()
if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites))
}
sites[0].Name = "mutated"
fresh := e.GetAllSites()
for _, s := range fresh {
if s.Name == "mutated" {
t.Error("GetAllSites returned reference, not copy")
}
}
}
func TestGetLiveState_ReturnsCopy(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
SiteState: models.SiteState{Status: "UP"},
})
state := e.GetLiveState()
state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}}
fresh := e.GetLiveState()
if fresh[1].Name == "mutated" {
t.Error("GetLiveState returned reference, not copy")
}
}
-217
View File
@@ -1,217 +0,0 @@
package monitor
import (
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
type SLAReport struct {
Window time.Duration
UptimePct float64
Downtime time.Duration
OutageCount int
LongestOut time.Duration
MTTR time.Duration
MTBF time.Duration
}
func ComputeSLA(changes []models.StateChange, currentStatus models.Status, window time.Duration) SLAReport {
now := time.Now()
windowStart := now.Add(-window)
report := SLAReport{Window: window}
if len(changes) == 0 {
if models.Status(currentStatus).IsBroken() {
report.UptimePct = 0
report.Downtime = window
} else {
report.UptimePct = 100
}
return report
}
// Sort changes chronologically (they come in DESC from DB).
sorted := make([]models.StateChange, len(changes))
copy(sorted, changes)
for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
// Determine status at window start: last transition before or at windowStart.
statusAtStart := string(models.StatusUp)
for i := len(sorted) - 1; i >= 0; i-- {
if !sorted[i].ChangedAt.After(windowStart) {
statusAtStart = sorted[i].ToStatus
break
}
}
var upTime, downTime time.Duration
var outages []time.Duration
cursor := windowStart
wasDown := models.Status(statusAtStart).IsBroken()
if wasDown {
report.OutageCount = 1
}
var outageStart time.Time
if wasDown {
outageStart = windowStart
}
for _, sc := range sorted {
if sc.ChangedAt.Before(windowStart) {
continue
}
if sc.ChangedAt.After(now) {
break
}
seg := sc.ChangedAt.Sub(cursor)
if wasDown {
downTime += seg
} else {
upTime += seg
}
newDown := models.Status(sc.ToStatus).IsBroken()
if !wasDown && newDown {
report.OutageCount++
outageStart = sc.ChangedAt
}
if wasDown && !newDown {
dur := sc.ChangedAt.Sub(outageStart)
outages = append(outages, dur)
}
wasDown = newDown
cursor = sc.ChangedAt
}
// Account for time from last change to now.
remaining := now.Sub(cursor)
if wasDown {
downTime += remaining
dur := now.Sub(outageStart)
outages = append(outages, dur)
} else {
upTime += remaining
}
total := upTime + downTime
if total > 0 {
report.UptimePct = float64(upTime) / float64(total) * 100
} else {
report.UptimePct = 100
}
report.Downtime = downTime
if len(outages) > 0 {
var totalOutage time.Duration
for _, d := range outages {
totalOutage += d
if d > report.LongestOut {
report.LongestOut = d
}
}
report.MTTR = totalOutage / time.Duration(len(outages))
}
if report.OutageCount > 0 && upTime > 0 {
report.MTBF = upTime / time.Duration(report.OutageCount)
}
return report
}
func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.Status, days int, now time.Time) []DayReport {
reports := make([]DayReport, days)
for i := 0; i < days; i++ {
dayStart := time.Date(now.Year(), now.Month(), now.Day()-i, 0, 0, 0, 0, now.Location())
dayEnd := time.Date(now.Year(), now.Month(), now.Day()-i+1, 0, 0, 0, 0, now.Location())
if i == 0 {
dayEnd = now
}
windowChanges := filterChangesForWindow(changes, dayStart, dayEnd)
statusAtStart := inferStatusAt(changes, dayStart)
sla := computeSLAForWindow(windowChanges, statusAtStart, dayStart, dayEnd)
reports[i] = DayReport{
Date: dayStart,
UptimePct: sla,
}
}
return reports
}
type DayReport struct {
Date time.Time
UptimePct float64
}
func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange {
var filtered []models.StateChange
for _, sc := range changes {
if !sc.ChangedAt.Before(start) && sc.ChangedAt.Before(end) {
filtered = append(filtered, sc)
}
}
return filtered
}
func inferStatusAt(changes []models.StateChange, at time.Time) string {
// Changes come DESC from DB. Walk backwards to find last change before `at`.
for _, sc := range changes {
if !sc.ChangedAt.After(at) {
return sc.ToStatus
}
}
return string(models.StatusUp)
}
func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 {
// Sort chronologically.
sorted := make([]models.StateChange, len(changes))
copy(sorted, changes)
for i, j := 0, len(sorted)-1; i < j; i, j = i+1, j-1 {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
var upTime, downTime time.Duration
cursor := start
wasDown := models.Status(statusAtStart).IsBroken()
for _, sc := range sorted {
if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) {
continue
}
seg := sc.ChangedAt.Sub(cursor)
if wasDown {
downTime += seg
} else {
upTime += seg
}
wasDown = models.Status(sc.ToStatus).IsBroken()
cursor = sc.ChangedAt
}
remaining := end.Sub(cursor)
if wasDown {
downTime += remaining
} else {
upTime += remaining
}
total := upTime + downTime
if total <= 0 {
return 100
}
return float64(upTime) / float64(total) * 100
}
-166
View File
@@ -1,166 +0,0 @@
package monitor
import (
"math"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestComputeSLA_NoChanges_CurrentlyUp(t *testing.T) {
r := ComputeSLA(nil, "UP", 24*time.Hour)
if r.UptimePct != 100 {
t.Errorf("expected 100%% uptime, got %.2f%%", r.UptimePct)
}
if r.Downtime != 0 {
t.Errorf("expected 0 downtime, got %v", r.Downtime)
}
}
func TestComputeSLA_NoChanges_CurrentlyDown(t *testing.T) {
r := ComputeSLA(nil, "DOWN", 24*time.Hour)
if r.UptimePct != 0 {
t.Errorf("expected 0%% uptime, got %.2f%%", r.UptimePct)
}
}
func TestComputeSLA_SingleOutage(t *testing.T) {
now := time.Now()
// DOWN 2 hours ago, recovered 1 hour ago → 1 hour downtime in 24h window
changes := []models.StateChange{
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)},
}
r := ComputeSLA(changes, "UP", 24*time.Hour)
if r.OutageCount != 1 {
t.Errorf("expected 1 outage, got %d", r.OutageCount)
}
expectedDowntime := 1 * time.Hour
if absDuration(r.Downtime-expectedDowntime) > time.Minute {
t.Errorf("expected ~1h downtime, got %v", r.Downtime)
}
expectedPct := float64(23) / float64(24) * 100
if math.Abs(r.UptimePct-expectedPct) > 0.5 {
t.Errorf("expected ~%.1f%% uptime, got %.2f%%", expectedPct, r.UptimePct)
}
if r.LongestOut < 55*time.Minute || r.LongestOut > 65*time.Minute {
t.Errorf("expected longest outage ~1h, got %v", r.LongestOut)
}
}
func TestComputeSLA_CurrentlyDown(t *testing.T) {
now := time.Now()
// Went down 3 hours ago, still down
changes := []models.StateChange{
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-3 * time.Hour)},
}
r := ComputeSLA(changes, "DOWN", 24*time.Hour)
if r.OutageCount != 1 {
t.Errorf("expected 1 outage, got %d", r.OutageCount)
}
expectedDowntime := 3 * time.Hour
if absDuration(r.Downtime-expectedDowntime) > time.Minute {
t.Errorf("expected ~3h downtime, got %v", r.Downtime)
}
}
func TestComputeSLA_MultipleOutages(t *testing.T) {
now := time.Now()
// Two outages: 6h-5h ago and 2h-1h ago
changes := []models.StateChange{
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)},
{ToStatus: "UP", ChangedAt: now.Add(-5 * time.Hour)},
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-6 * time.Hour)},
}
r := ComputeSLA(changes, "UP", 24*time.Hour)
if r.OutageCount != 2 {
t.Errorf("expected 2 outages, got %d", r.OutageCount)
}
expectedDowntime := 2 * time.Hour
if absDuration(r.Downtime-expectedDowntime) > time.Minute {
t.Errorf("expected ~2h downtime, got %v", r.Downtime)
}
if r.MTTR < 55*time.Minute || r.MTTR > 65*time.Minute {
t.Errorf("expected MTTR ~1h, got %v", r.MTTR)
}
}
func TestComputeSLA_LateNotDown(t *testing.T) {
now := time.Now()
// LATE for 2 hours is not downtime
changes := []models.StateChange{
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
{ToStatus: "LATE", FromStatus: "UP", ChangedAt: now.Add(-3 * time.Hour)},
}
r := ComputeSLA(changes, "UP", 24*time.Hour)
if r.OutageCount != 0 {
t.Errorf("expected 0 outages for LATE, got %d", r.OutageCount)
}
if r.UptimePct != 100 {
t.Errorf("expected 100%% uptime (LATE is not down), got %.2f%%", r.UptimePct)
}
}
func TestComputeDailyBreakdown(t *testing.T) {
// Use a fixed time well past midnight so the outage always falls within today's window.
now := time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC)
changes := []models.StateChange{
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)},
}
days := ComputeDailyBreakdown(changes, "UP", 7, now)
if len(days) != 7 {
t.Fatalf("expected 7 days, got %d", len(days))
}
// Today should have less than 100% uptime
if days[0].UptimePct >= 100 {
t.Errorf("expected today < 100%%, got %.2f%%", days[0].UptimePct)
}
}
func TestIsBroken(t *testing.T) {
if !models.StatusDown.IsBroken() {
t.Error("DOWN should be broken")
}
if !models.StatusSSLExp.IsBroken() {
t.Error("SSL EXP should be broken")
}
if models.StatusUp.IsBroken() {
t.Error("UP should not be broken")
}
if models.StatusLate.IsBroken() {
t.Error("LATE should not be broken")
}
if models.StatusStale.IsBroken() {
t.Error("STALE should not be broken")
}
if models.StatusPending.IsBroken() {
t.Error("PENDING should not be broken")
}
}
func absDuration(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
+9 -88
View File
@@ -3,22 +3,10 @@ package server
import ( import (
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
) )
// maxVisitors caps the rate-limiter map so a flood of distinct keys can't grow
// it without bound. With the trusted-proxy gate below, keys come from real peer
// addresses, so this is a defense-in-depth ceiling rather than the primary
// guard.
const maxVisitors = 10000
const (
visitorCleanupInterval = 5 * time.Minute
visitorIdleCutoff = 10 * time.Minute
)
type visitor struct { type visitor struct {
tokens float64 tokens float64
lastSeen time.Time lastSeen time.Time
@@ -29,26 +17,18 @@ type RateLimiter struct {
visitors map[string]*visitor visitors map[string]*visitor
rate float64 rate float64
burst float64 burst float64
trusted []*net.IPNet
stop chan struct{}
} }
func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter { func NewRateLimiter(requestsPerMinute int) *RateLimiter {
rl := &RateLimiter{ rl := &RateLimiter{
visitors: make(map[string]*visitor), visitors: make(map[string]*visitor),
rate: float64(requestsPerMinute) / 60.0, rate: float64(requestsPerMinute) / 60.0,
burst: float64(requestsPerMinute), burst: float64(requestsPerMinute),
trusted: trusted,
stop: make(chan struct{}),
} }
go rl.cleanup() go rl.cleanup()
return rl return rl
} }
func (rl *RateLimiter) Stop() {
close(rl.stop)
}
func (rl *RateLimiter) Allow(ip string) bool { func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -57,9 +37,6 @@ func (rl *RateLimiter) Allow(ip string) bool {
now := time.Now() now := time.Now()
if !exists { if !exists {
if len(rl.visitors) >= maxVisitors {
rl.evictOldest()
}
rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now} rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now}
return true return true
} }
@@ -78,90 +55,34 @@ func (rl *RateLimiter) Allow(ip string) bool {
return true return true
} }
// evictOldest removes the least-recently-seen visitor. Called only when the map
// is at capacity, so the O(n) scan is rare. Caller holds rl.mu.
func (rl *RateLimiter) evictOldest() {
var oldestKey string
var oldest time.Time
for k, v := range rl.visitors {
if oldestKey == "" || v.lastSeen.Before(oldest) {
oldestKey = k
oldest = v.lastSeen
}
}
if oldestKey != "" {
delete(rl.visitors, oldestKey)
}
}
func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(visitorCleanupInterval)
defer ticker.Stop()
for { for {
select { time.Sleep(5 * time.Minute)
case <-ticker.C:
rl.mu.Lock() rl.mu.Lock()
cutoff := time.Now().Add(-visitorIdleCutoff) cutoff := time.Now().Add(-10 * time.Minute)
for ip, v := range rl.visitors { for ip, v := range rl.visitors {
if v.lastSeen.Before(cutoff) { if v.lastSeen.Before(cutoff) {
delete(rl.visitors, ip) delete(rl.visitors, ip)
} }
} }
rl.mu.Unlock() rl.mu.Unlock()
case <-rl.stop:
return
}
} }
} }
// clientIP determines the rate-limit key for a request. X-Forwarded-For is only func clientIP(r *http.Request) string {
// honored when the immediate peer (RemoteAddr) is a configured trusted proxy; if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// otherwise the header is attacker-controlled and ignored, so a spoofed XFF return fwd
// can't mint unlimited distinct keys (rate-limit bypass + memory DoS). When the }
// peer is trusted, the right-most address that is not itself a trusted proxy is
// the real client (RFC 7239 right-most-untrusted-hop).
func clientIP(r *http.Request, trusted []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
host = r.RemoteAddr return r.RemoteAddr
}
if len(trusted) == 0 || !ipInCIDRs(net.ParseIP(host), trusted) {
return host
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return host
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !ipInCIDRs(ip, trusted) {
return ip.String()
}
} }
return host return host
} }
func ipInCIDRs(ip net.IP, cidrs []*net.IPNet) bool {
if ip == nil {
return false
}
for _, c := range cidrs {
if c.Contains(ip) {
return true
}
}
return false
}
func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc { func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow(clientIP(r, limiter.trusted)) { if !limiter.Allow(clientIP(r)) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return return
} }
+409 -472
View File
@@ -5,419 +5,21 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log"
"net"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics" "gitea.lerkolabs.com/lerko/uptop/internal/metrics"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
) )
const maxRequestBody = 1 << 20 const maxRequestBody = 1 << 20
type ServerConfig struct {
Port int
EnableStatus bool
Title string
ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
TrustedProxies []*net.IPNet
QuietHTTPLog bool
}
type Server struct {
cfg ServerConfig
store store.Store
eng *monitor.Engine
pushRL *RateLimiter
probeRL *RateLimiter
backupRL *RateLimiter
statusRL *RateLimiter
}
const (
pushRateLimit = 60
probeRateLimit = 30
backupRateLimit = 10
statusRateLimit = 120
httpReadHeaderTimeout = 10 * time.Second
httpReadTimeout = 30 * time.Second
httpWriteTimeout = 60 * time.Second
httpIdleTimeout = 120 * time.Second
)
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
return &Server{
cfg: cfg,
store: s,
eng: eng,
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
}
}
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
srv := NewServer(cfg, s, eng)
return srv.Start()
}
func (s *Server) Start() *http.Server {
if s.cfg.ClusterKey == "" {
slog.Warn("no UPTOP_CLUSTER_SECRET set, cluster API endpoints will reject all requests")
}
if s.cfg.ClusterMode != "" && s.cfg.ClusterMode != "leader" && s.cfg.TLSCert == "" {
slog.Warn("cluster mode active without TLS, secrets transmitted in cleartext")
}
handler := s.routes()
addr := fmt.Sprintf(":%d", s.cfg.Port)
httpSrv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: httpReadHeaderTimeout,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
}
go func() {
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
slog.Info("HTTPS server listening", "addr", addr)
if err := httpSrv.ListenAndServeTLS(s.cfg.TLSCert, s.cfg.TLSKey); err != nil && err != http.ErrServerClosed {
slog.Error("HTTPS server failed", "err", err)
}
} else {
slog.Info("HTTP server listening", "addr", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server failed", "err", err)
}
}
}()
return httpSrv
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/push", RateLimit(s.pushRL, s.handlePush))
mux.HandleFunc("/api/health", s.handleHealth)
mux.HandleFunc("/api/backup/export", RateLimit(s.backupRL, s.handleExport))
mux.HandleFunc("/api/backup/import", RateLimit(s.backupRL, s.handleImport))
mux.HandleFunc("/api/import/kuma", RateLimit(s.backupRL, s.handleKumaImport))
mux.HandleFunc("/api/probe/register", RateLimit(s.probeRL, s.handleProbeRegister))
mux.HandleFunc("/api/probe/assignments", RateLimit(s.probeRL, s.handleProbeAssignments))
mux.HandleFunc("/api/probe/results", RateLimit(s.probeRL, s.handleProbeResults))
mux.HandleFunc("/metrics", s.handleMetrics)
if s.cfg.EnableStatus {
mux.HandleFunc("/status", RateLimit(s.statusRL, s.handleStatus))
mux.HandleFunc("/status/json", RateLimit(s.statusRL, s.handleStatusJSON))
}
handler := securityHeadersMiddleware(mux)
if !s.cfg.QuietHTTPLog {
handler = loggingMiddleware(s.cfg.TrustedProxies, handler)
}
if s.cfg.TLSCert != "" {
handler = hstsMiddleware(handler)
}
return handler
}
func (s *Server) requireAuth(r *http.Request) bool {
return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey)
}
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := extractBearerToken(r)
if token == "" {
if qt := r.URL.Query().Get("token"); qt != "" {
token = qt
slog.Warn("push token in query string is deprecated, use Authorization: Bearer header")
}
}
if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest)
return
}
if s.eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
} else {
http.Error(w, "Invalid Token", http.StatusNotFound)
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return
}
data, err := s.store.ExportData(r.Context())
if err != nil {
slog.Error("export failed", "err", err)
http.Error(w, "Export failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("redact_secrets") != "false" {
for i := range data.Alerts {
data.Alerts[i].Settings = models.RedactAlertSettings(data.Alerts[i].Type, data.Alerts[i].Settings)
}
}
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// API import never modifies users — cluster-secret holder shouldn't be
// able to replace admin accounts. CLI restore still does full import.
data.Users = nil
if err := s.store.ImportData(r.Context(), data); err != nil {
slog.Error("import failed", "err", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("Import Successful (users excluded — manage via CLI or UPTOP_KEYS)"))
}
func (s *Server) handleKumaImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
slog.Error("invalid Kuma JSON", "err", err)
http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return
}
backup := importer.ConvertKuma(&kb)
if err := s.store.ImportData(r.Context(), backup); err != nil {
slog.Error("Kuma import failed", "err", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}
func (s *Server) handleProbeRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.store.RegisterNode(r.Context(), models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil {
slog.Error("probe registration failed", "err", err)
http.Error(w, "Registration failed", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}
func (s *Server) handleProbeAssignments(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
nodeID := r.URL.Query().Get("node_id")
var nodeRegion string
if nodeID != "" {
if node, err := s.store.GetNode(r.Context(), nodeID); err == nil {
nodeRegion = node.Region
}
}
sites := s.eng.GetAllSites()
var assigned []models.Site
for _, site := range sites {
if site.Paused || site.Type == "push" || site.Type == "group" {
continue
}
if site.Regions != "" && nodeRegion != "" {
matched := false
for _, reg := range strings.Split(site.Regions, ",") {
if strings.TrimSpace(reg) == nodeRegion {
matched = true
break
}
}
if !matched {
continue
}
}
assigned = append(assigned, site)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}
func (s *Server) handleProbeResults(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
NodeID string `json:"node_id"`
Results []struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
ErrorReason string `json:"error_reason"`
} `json:"results"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.NodeID == "" {
http.Error(w, "node_id is required", http.StatusBadRequest)
return
}
for _, result := range req.Results {
s.eng.EnqueueProbeCheck(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp)
s.eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason)
}
if err := s.store.UpdateNodeLastSeen(r.Context(), req.NodeID); err != nil {
slog.Error("node last-seen update failed", "err", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.cfg.MetricsPublic {
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
metrics.Handler(s.eng)(w, r)
}
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
renderStatusPage(w, s.cfg.Title, s.eng)
}
func (s *Server) handleStatusJSON(w http.ResponseWriter, r *http.Request) {
state := s.eng.GetLiveState()
activeWindows, _ := s.store.GetActiveMaintenanceWindows(r.Context())
maintSet := make(map[int]bool)
allInMaint := false
for _, mw := range activeWindows {
if mw.Type != "maintenance" {
continue
}
if mw.MonitorID == 0 {
allInMaint = true
} else {
maintSet[mw.MonitorID] = true
}
}
public := make(map[int]statusSite, len(state))
for id, site := range state {
displayStatus := string(site.Status)
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
displayStatus = "MAINT"
}
public[id] = statusSite{
Name: site.Name,
Type: site.Type,
URL: site.URL,
Status: displayStatus,
Paused: site.Paused,
LastCheck: site.LastCheck,
Latency: site.Latency,
}
}
if s.cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", s.cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(public) //nolint:errcheck
}
// --- Helpers ---
func checkSecret(got, want string) bool { func checkSecret(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
} }
@@ -430,78 +32,21 @@ func extractBearerToken(r *http.Request) string {
return "" return ""
} }
// statusSite is the public DTO for /status/json. var sensitiveKeys = map[string]bool{
type statusSite struct { "pass": true, "password": true, "token": true,
Name string "routing_key": true, "user": true, "username": true,
Type string
URL string
Status string
Paused bool
LastCheck time.Time
Latency time.Duration
} }
// --- Middleware --- func redactSettings(settings map[string]string) map[string]string {
redacted := make(map[string]string, len(settings))
type statusWriter struct { for k, v := range settings {
http.ResponseWriter if sensitiveKeys[k] && v != "" {
code int redacted[k] = "***REDACTED***"
} } else {
redacted[k] = v
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(trusted []*net.IPNet, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(sw, r)
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
slog.Info("http request", "method", r.Method, "path", path, "status", sw.code, "duration", time.Since(start).Round(time.Millisecond), "ip", clientIP(r, trusted)) //nolint:gosec // structured slog, not format string
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
sites := eng.GetAllSites()
sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status {
if sites[i].Status == models.StatusDown {
return true
}
if sites[j].Status == models.StatusDown {
return false
} }
} }
return sites[i].Name < sites[j].Name return redacted
})
data := struct {
Title string
Sites []models.Site
}{Title: title, Sites: sites}
if err := statusTpl.Execute(w, data); err != nil {
slog.Error("status page render failed", "err", err)
}
} }
var statusTpl = template.Must(template.New("status").Parse(` var statusTpl = template.Must(template.New("status").Parse(`
@@ -635,3 +180,395 @@ var statusTpl = template.Must(template.New("status").Parse(`
</script> </script>
</body> </body>
</html>`)) </html>`))
type ServerConfig struct {
Port int
EnableStatus bool
Title string
ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
}
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
}
pushRL := NewRateLimiter(60)
probeRL := NewRateLimiter(30)
backupRL := NewRateLimiter(10)
statusRL := NewRateLimiter(120)
mux := http.NewServeMux()
// 1. Push Heartbeat
mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := extractBearerToken(r)
if token == "" {
if qt := r.URL.Query().Get("token"); qt != "" {
token = qt
log.Printf("DEPRECATED: push token in query string — use Authorization: Bearer header instead")
}
}
if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest)
return
}
if eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
} else {
http.Error(w, "Invalid Token", http.StatusNotFound)
}
}))
// 2. Health Check (For Cluster Follower)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// 3. Config Export
mux.HandleFunc("/api/backup/export", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return
}
data, err := s.ExportData()
if err != nil {
log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("redact_secrets") != "false" {
for i := range data.Alerts {
data.Alerts[i].Settings = redactSettings(data.Alerts[i].Settings)
}
}
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}))
// 4. Config Import
mux.HandleFunc("/api/backup/import", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := s.ImportData(data); err != nil {
log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("Import Successful"))
}))
// 5. Kuma Import
mux.HandleFunc("/api/import/kuma", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err)
http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return
}
backup := importer.ConvertKuma(&kb)
if err := s.ImportData(backup); err != nil {
log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}))
// 6. Probe Registration
mux.HandleFunc("/api/probe/register", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.RegisterNode(models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil {
log.Printf("Probe register failed: %v", err)
http.Error(w, "Registration failed", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
// 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
nodeID := r.URL.Query().Get("node_id")
var nodeRegion string
if nodeID != "" {
if node, err := s.GetNode(nodeID); err == nil {
nodeRegion = node.Region
}
}
sites := eng.GetAllSites()
var assigned []models.Site
for _, site := range sites {
if site.Paused || site.Type == "push" || site.Type == "group" {
continue
}
if site.Regions != "" && nodeRegion != "" {
matched := false
for _, r := range strings.Split(site.Regions, ",") {
if strings.TrimSpace(r) == nodeRegion {
matched = true
break
}
}
if !matched {
continue
}
}
assigned = append(assigned, site)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}))
// 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
NodeID string `json:"node_id"`
Results []struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
ErrorReason string `json:"error_reason"`
} `json:"results"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.NodeID == "" {
http.Error(w, "node_id is required", http.StatusBadRequest)
return
}
for _, result := range req.Results {
if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil {
log.Printf("Failed to save probe result: %v", err)
}
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason)
}
if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
log.Printf("Failed to update node last seen: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
// 9. Prometheus Metrics
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !cfg.MetricsPublic && cfg.ClusterKey != "" {
if !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
metrics.Handler(eng)(w, r)
})
// 10. Status Page
if cfg.EnableStatus {
mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }))
mux.HandleFunc("/status/json", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) {
state := eng.GetLiveState()
activeWindows, _ := s.GetActiveMaintenanceWindows()
maintSet := make(map[int]bool)
allInMaint := false
for _, mw := range activeWindows {
if mw.Type != "maintenance" {
continue
}
if mw.MonitorID == 0 {
allInMaint = true
} else {
maintSet[mw.MonitorID] = true
}
}
for id, site := range state {
site.Token = ""
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
site.Status = "MAINT"
}
state[id] = site
}
if cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck
}))
}
if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" {
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
}
handler := loggingMiddleware(securityHeadersMiddleware(mux))
if cfg.TLSCert != "" {
handler = hstsMiddleware(handler)
}
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if cfg.TLSCert != "" && cfg.TLSKey != "" {
fmt.Printf("HTTPS Server listening on %s\n", addr)
if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed {
log.Printf("HTTPS server error: %v", err)
}
} else {
fmt.Printf("HTTP Server listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP server error: %v", err)
}
}
}()
return srv
}
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(sw, r)
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
sites := eng.GetAllSites()
sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status {
if sites[i].Status == "DOWN" {
return true
}
if sites[j].Status == "DOWN" {
return false
}
}
return sites[i].Name < sites[j].Name
})
data := struct {
Title string
Sites []models.Site
}{Title: title, Sites: sites}
if err := statusTpl.Execute(w, data); err != nil {
log.Printf("Failed to render status page: %v", err)
}
}
+80 -205
View File
@@ -2,7 +2,6 @@ package server
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
@@ -11,17 +10,15 @@ import (
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store --- // --- Mock Store ---
type mockStore struct { type mockStore struct {
storetest.BaseMock
mu sync.Mutex mu sync.Mutex
sites []models.SiteConfig sites []models.Site
alerts []models.AlertConfig alerts []models.AlertConfig
nodes map[string]models.ProbeNode nodes map[string]models.ProbeNode
importedData *models.Backup importedData *models.Backup
@@ -35,26 +32,69 @@ func newMockStore() *mockStore {
} }
} }
func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
return m.alerts, nil func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
return nil
} }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
}
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) Close() error { return nil }
func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) { func (m *mockStore) ExportData() (models.Backup, error) {
return models.Backup{ return models.Backup{
Sites: m.sites, Sites: m.sites,
Alerts: m.alerts, Alerts: m.alerts,
}, nil }, nil
} }
func (m *mockStore) ImportData(_ context.Context, data models.Backup) error { func (m *mockStore) ImportData(data models.Backup) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.importedData = &data m.importedData = &data
return nil return nil
} }
func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error { func (m *mockStore) RegisterNode(node models.ProbeNode) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.registeredNodes = append(m.registeredNodes, node) m.registeredNodes = append(m.registeredNodes, node)
@@ -62,7 +102,7 @@ func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error
return nil return nil
} }
func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, error) { func (m *mockStore) GetNode(id string) (models.ProbeNode, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if n, ok := m.nodes[id]; ok { if n, ok := m.nodes[id]; ok {
@@ -71,18 +111,14 @@ func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, err
return models.ProbeNode{}, fmt.Errorf("not found") return models.ProbeNode{}, fmt.Errorf("not found")
} }
func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) { func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return m.maintWindows, nil return m.maintWindows, nil
} }
// --- Helpers --- // --- Helpers ---
func freePort(t *testing.T) int { func freePort() int {
t.Helper() ln, _ := net.Listen("tcp", "127.0.0.1:0")
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
port := ln.Addr().(*net.TCPAddr).Port port := ln.Addr().(*net.TCPAddr).Port
ln.Close() ln.Close()
return port return port
@@ -99,7 +135,7 @@ func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServ
t.Helper() t.Helper()
ms := newMockStore() ms := newMockStore()
eng := monitor.NewEngine(ms) eng := monitor.NewEngine(ms)
port := freePort(t) port := freePort()
srv := Start(ServerConfig{ srv := Start(ServerConfig{
Port: port, Port: port,
@@ -145,7 +181,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)
} }
@@ -223,8 +259,8 @@ func TestHealth_WrongSecret(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 401 {
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode) t.Errorf("expected 401, got %d", resp.StatusCode)
} }
} }
@@ -256,7 +292,7 @@ func TestExport_Unauthorized_WrongKey(t *testing.T) {
func TestExport_Success(t *testing.T) { func TestExport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
ts.store.sites = []models.SiteConfig{{ID: 1, Name: "example", URL: "http://example.com"}} ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}}
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil) resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
if err != nil { if err != nil {
@@ -289,10 +325,7 @@ func TestImport_MethodNotAllowed(t *testing.T) {
func TestImport_Unauthorized(t *testing.T) { func TestImport_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(models.Backup{}) body, _ := json.Marshal(models.Backup{})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body) resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -306,12 +339,9 @@ func TestImport_Unauthorized(t *testing.T) {
func TestImport_Success(t *testing.T) { func TestImport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
backup := models.Backup{ backup := models.Backup{
Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}}, Sites: []models.Site{{Name: "imported", URL: "http://example.com"}},
}
body, err := json.Marshal(backup)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
} }
body, _ := json.Marshal(backup)
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -343,12 +373,9 @@ func TestImport_InvalidJSON(t *testing.T) {
func TestProbeRegister_Success(t *testing.T) { func TestProbeRegister_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
"id": "node-1", "name": "US East", "region": "us-east", "id": "node-1", "name": "US East", "region": "us-east",
}) })
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -369,10 +396,7 @@ func TestProbeRegister_Success(t *testing.T) {
func TestProbeRegister_MissingID(t *testing.T) { func TestProbeRegister_MissingID(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(map[string]string{"name": "test"}) body, _ := json.Marshal(map[string]string{"name": "test"})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -385,10 +409,7 @@ func TestProbeRegister_MissingID(t *testing.T) {
func TestProbeRegister_Unauthorized(t *testing.T) { func TestProbeRegister_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(map[string]string{"id": "node-1"}) body, _ := json.Marshal(map[string]string{"id": "node-1"})
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body) resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -403,15 +424,12 @@ func TestProbeRegister_Unauthorized(t *testing.T) {
func TestProbeResults_Success(t *testing.T) { func TestProbeResults_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"node_id": "node-1", "node_id": "node-1",
"results": []map[string]any{ "results": []map[string]any{
{"site_id": 1, "latency_ns": 5000000, "is_up": true}, {"site_id": 1, "latency_ns": 5000000, "is_up": true},
}, },
}) })
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -424,12 +442,9 @@ func TestProbeResults_Success(t *testing.T) {
func TestProbeResults_MissingNodeID(t *testing.T) { func TestProbeResults_MissingNodeID(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
body, err := json.Marshal(map[string]any{ body, _ := json.Marshal(map[string]any{
"results": []map[string]any{}, "results": []map[string]any{},
}) })
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -454,32 +469,15 @@ func TestStatusPage_Enabled(t *testing.T) {
} }
} }
func TestStatusJSON_PublicDTOOnly(t *testing.T) { func TestStatusJSON_TokensStripped(t *testing.T) {
ts := newTestServer(t, "secret", true) ts := newTestServer(t, "secret", true)
// Seed a push monitor (no network IO) through the store and start the // Inject a site with a token into engine state
// engine so its poll loop loads it into live state — the path real sites ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"})
// take. The old version of this test injected via UpdateSiteConfig, which // Need to inject directly since UpdateSiteConfig only updates existing
// no-ops for unknown IDs, so it asserted over zero sites and passed func() {
// against a server that leaked tokens. ts.engine.RecordHeartbeat("unused") // just to exercise, won't match
ts.store.sites = []models.SiteConfig{{ }()
ID: 1, Name: "test", Type: "push", Token: "secret-token",
Hostname: "internal-host", AlertID: 3,
}}
ctx, cancel := context.WithCancel(context.Background())
ts.engine.Start(ctx)
t.Cleanup(func() {
cancel()
ts.engine.Stop()
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && len(ts.engine.GetLiveState()) == 0 {
time.Sleep(10 * time.Millisecond)
}
if len(ts.engine.GetLiveState()) == 0 {
t.Fatal("engine never loaded the seeded site")
}
resp, err := http.Get(ts.baseURL + "/status/json") resp, err := http.Get(ts.baseURL + "/status/json")
if err != nil { if err != nil {
@@ -489,23 +487,11 @@ func TestStatusJSON_PublicDTOOnly(t *testing.T) {
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode) t.Errorf("expected 200, got %d", resp.StatusCode)
} }
var state map[string]models.Site
// Decode raw so absent struct fields can't mask leaked JSON keys. json.NewDecoder(resp.Body).Decode(&state)
var state map[string]map[string]any
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
t.Fatal(err)
}
if len(state) != 1 {
t.Fatalf("expected 1 site in status JSON, got %d", len(state))
}
for _, site := range state { for _, site := range state {
if site["Name"] != "test" { if site.Token != "" {
t.Errorf("expected Name to be public, got %v", site["Name"]) t.Error("expected token stripped from status JSON response")
}
for _, leaked := range []string{"Token", "LastError", "Hostname", "Port", "DNSServer", "AlertID", "AcceptedCodes", "Interval"} {
if _, ok := site[leaked]; ok {
t.Errorf("status JSON leaks internal field %q", leaked)
}
} }
} }
} }
@@ -568,114 +554,3 @@ func TestProbeAssignments_Unauthorized(t *testing.T) {
t.Errorf("expected 401, got %d", resp.StatusCode) t.Errorf("expected 401, got %d", resp.StatusCode)
} }
} }
// --- Security: X-Forwarded-For trusted-proxy handling ---
func mustCIDR(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func TestClientIP_TrustedProxyHandling(t *testing.T) {
trusted := []*net.IPNet{mustCIDR(t, "10.0.0.0/8")}
tests := []struct {
name string
remoteAddr string
xff string
trusted []*net.IPNet
want string
}{
{"no trusted proxies ignores XFF", "203.0.113.9:5000", "1.2.3.4", nil, "203.0.113.9"},
{"untrusted peer ignores XFF", "203.0.113.9:5000", "1.2.3.4", trusted, "203.0.113.9"},
{"trusted peer honors XFF", "10.0.0.5:5000", "1.2.3.4", trusted, "1.2.3.4"},
{"trusted peer, rightmost-untrusted hop", "10.0.0.5:5000", "1.2.3.4, 10.0.0.9", trusted, "1.2.3.4"},
{"trusted peer, no XFF falls back to peer", "10.0.0.5:5000", "", trusted, "10.0.0.5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
r.RemoteAddr = tt.remoteAddr
if tt.xff != "" {
r.Header.Set("X-Forwarded-For", tt.xff)
}
if got := clientIP(r, tt.trusted); got != tt.want {
t.Errorf("clientIP = %q, want %q", got, tt.want)
}
})
}
}
// A spoofed, rotating X-Forwarded-For from an untrusted peer must NOT bypass
// the limiter: all requests key on the real RemoteAddr, so the bucket trips.
func TestRateLimit_SpoofedXFFCannotBypass(t *testing.T) {
rl := NewRateLimiter(60, nil) // no trusted proxies
allowed := 0
for i := 0; i < 200; i++ {
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("http.NewRequest: %v", err)
}
r.RemoteAddr = "203.0.113.9:5000"
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i%256))
if rl.Allow(clientIP(r, rl.trusted)) {
allowed++
}
}
if allowed > 60 {
t.Errorf("spoofed XFF bypassed limiter: %d/200 allowed (burst is 60)", allowed)
}
}
func TestRateLimit_VisitorMapBounded(t *testing.T) {
rl := NewRateLimiter(60, nil)
for i := 0; i < maxVisitors+500; i++ {
rl.Allow(fmt.Sprintf("10.1.%d.%d", i/256, i%256))
}
rl.mu.Lock()
n := len(rl.visitors)
rl.mu.Unlock()
if n > maxVisitors {
t.Errorf("visitor map exceeded cap: %d > %d", n, maxVisitors)
}
}
// --- Security: export redaction allowlist ---
func TestRedactByProvider(t *testing.T) {
tests := []struct {
name string
typ string
in map[string]string
redacted []string // keys expected to be ***REDACTED***
kept []string // keys expected to survive verbatim
}{
{"discord url is secret", "discord", map[string]string{"url": "https://discord.com/api/webhooks/1/abc"}, []string{"url"}, nil},
{"opsgenie api_key redacted, priority kept", "opsgenie", map[string]string{"api_key": "k", "priority": "P1", "eu": "true"}, []string{"api_key"}, []string{"priority", "eu"}},
{"email creds redacted, routing kept", "email", map[string]string{"host": "smtp.x.com", "port": "587", "to": "a@x.com", "from": "b@x.com", "user": "u", "pass": "p"}, []string{"user", "pass"}, []string{"host", "port", "to", "from"}},
{"telegram token redacted, chat_id kept", "telegram", map[string]string{"token": "123:ABC", "chat_id": "42"}, []string{"token"}, []string{"chat_id"}},
{"unknown provider redacts everything", "mystery", map[string]string{"anything": "x"}, []string{"anything"}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := models.RedactAlertSettings(tt.typ, tt.in)
for _, k := range tt.redacted {
if out[k] != "***REDACTED***" {
t.Errorf("key %q: expected redacted, got %q", k, out[k])
}
}
for _, k := range tt.kept {
if out[k] != tt.in[k] {
t.Errorf("key %q: expected kept %q, got %q", k, tt.in[k], out[k])
}
}
})
}
}
+2 -8
View File
@@ -75,14 +75,8 @@ func TestEncryptorUniqueCiphertexts(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
a, err := enc.Encrypt("same") a, _ := enc.Encrypt("same")
if err != nil { b, _ := enc.Encrypt("same")
t.Fatalf("first encrypt: %v", err)
}
b, err := enc.Encrypt("same")
if err != nil {
t.Fatalf("second encrypt: %v", err)
}
if a == b { if a == b {
t.Error("two encryptions of same plaintext should produce different ciphertexts") t.Error("two encryptions of same plaintext should produce different ciphertexts")
} }
+1 -9
View File
@@ -5,23 +5,15 @@ import (
"strconv" "strconv"
) )
type Migration struct {
Version int
SQL string
}
type Dialect interface { type Dialect interface {
DriverName() string DriverName() string
CreateTablesSQL() []string CreateTablesSQL() []string
Migrations() []Migration MigrationsSQL() []string
BaselineVersion() int
BoolFalse() string BoolFalse() string
ResetSequenceOnEmpty(db *sql.DB, table string) ResetSequenceOnEmpty(db *sql.DB, table string)
ImportWipe(tx *sql.Tx) ImportWipe(tx *sql.Tx)
ImportWipeUsers(tx *sql.Tx)
ImportResetSequences(tx *sql.Tx) ImportResetSequences(tx *sql.Tx)
UpsertNodeSQL() string UpsertNodeSQL() string
UpsertAlertHealthSQL() string
} }
func rewritePlaceholders(query string, dollarStyle bool) string { func rewritePlaceholders(query string, dollarStyle bool) string {
+34 -69
View File
@@ -2,7 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log/slog" "log"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -15,7 +15,6 @@ func NewPostgresStore(connStr string) (*SQLStore, error) {
func (d *PostgresDialect) DriverName() string { return "postgres" } func (d *PostgresDialect) DriverName() string { return "postgres" }
func (d *PostgresDialect) BoolFalse() string { return "FALSE" } func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
func (d *PostgresDialect) BaselineVersion() int { return 21 }
func (d *PostgresDialect) CreateTablesSQL() []string { func (d *PostgresDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -33,8 +32,7 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE, ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE
regions TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -44,21 +42,20 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
site_id INTEGER NOT NULL, latency_ns BIGINT, site_id INTEGER NOT NULL, latency_ns BIGINT,
is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW(), is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW()
node_id TEXT DEFAULT ''
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
region TEXT DEFAULT '', region TEXT DEFAULT '',
last_seen TIMESTAMPTZ DEFAULT NOW(), last_seen TIMESTAMP DEFAULT NOW(),
version TEXT DEFAULT '' version TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS logs ( `CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
message TEXT NOT NULL, message TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS maintenance_windows ( `CREATE TABLE IF NOT EXISTS maintenance_windows (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -66,10 +63,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT DEFAULT '', description TEXT DEFAULT '',
type TEXT DEFAULT 'maintenance', type TEXT DEFAULT 'maintenance',
start_time TIMESTAMPTZ NOT NULL, start_time TIMESTAMP NOT NULL,
end_time TIMESTAMPTZ, end_time TIMESTAMP,
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences ( `CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -81,43 +78,27 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
from_status TEXT NOT NULL, from_status TEXT NOT NULL,
to_status TEXT NOT NULL, to_status TEXT NOT NULL,
error_reason TEXT DEFAULT '', error_reason TEXT DEFAULT '',
changed_at TIMESTAMPTZ DEFAULT NOW() changed_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
`CREATE TABLE IF NOT EXISTS alert_health (
alert_id INTEGER PRIMARY KEY,
last_send_at TIMESTAMPTZ,
last_send_ok BOOLEAN DEFAULT FALSE,
last_error TEXT DEFAULT '',
send_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0
)`,
} }
} }
func (d *PostgresDialect) Migrations() []Migration { func (d *PostgresDialect) MigrationsSQL() []string {
return []Migration{ return []string{
{1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
{2, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
{3, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0",
{4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'",
{5, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''",
{6, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0",
{7, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'",
{8, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''",
{9, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''",
{10, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE",
{11, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE",
{12, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''"}, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''",
{13, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''",
{14, "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'"},
{15, "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'"},
{16, "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"},
{17, "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'"},
{18, "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'"},
{19, "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"},
{20, "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'"},
{21, "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'"},
} }
} }
@@ -125,50 +106,34 @@ func (d *PostgresDialect) UpsertNodeSQL() string {
return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version" return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version"
} }
func (d *PostgresDialect) UpsertAlertHealthSQL() string {
return "INSERT INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (alert_id) DO UPDATE SET last_send_at = EXCLUDED.last_send_at, last_send_ok = EXCLUDED.last_send_ok, last_error = EXCLUDED.last_error, send_count = EXCLUDED.send_count, fail_count = EXCLUDED.fail_count"
}
func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {} func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "sites", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "alerts", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil {
log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE check_history RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "check_history", "err", err)
}
if _, err := tx.Exec("TRUNCATE TABLE state_changes RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "state_changes", "err", err)
}
if _, err := tx.Exec("TRUNCATE TABLE alert_health RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "alert_health", "err", err)
}
}
func (d *PostgresDialect) ImportWipeUsers(tx *sql.Tx) {
if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "users", "err", err)
} }
} }
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil { if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil {
slog.Debug("sequence reset failed", "table", "sites", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil { if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil {
slog.Debug("sequence reset failed", "table", "alerts", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil { if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil {
slog.Debug("sequence reset failed", "table", "users", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil { if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil {
slog.Debug("sequence reset failed", "table", "maintenance_windows", "err", err) log.Printf("sequence reset error: %v", err)
} }
} }
+36 -79
View File
@@ -2,43 +2,26 @@ package store
import ( import (
"database/sql" "database/sql"
"fmt" "log"
"log/slog"
"os"
_ "modernc.org/sqlite" _ "github.com/mattn/go-sqlite3"
) )
type SQLiteDialect struct{} type SQLiteDialect struct{}
func NewSQLiteStore(path string) (*SQLStore, error) { func NewSQLiteStore(path string) (*SQLStore, error) {
// Apply pragmas via the DSN so every pooled connection gets them — a s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
// post-open PRAGMA Exec only affects a single connection. WAL allows
// concurrent readers alongside the single writer goroutine; busy_timeout
// rides out brief lock contention; synchronous=NORMAL is durable under WAL
// and far faster than the FULL default. (:memory: is left untouched —
// these pragmas are no-ops or harmful for the in-memory test DB.)
dsn := path
if path != ":memory:" {
dsn = fmt.Sprintf("file:%s?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)&_pragma=synchronous(normal)", path)
}
s, err := NewSQLStore("sqlite", dsn, &SQLiteDialect{})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if path != ":memory:" { if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
for _, suffix := range []string{"", "-wal", "-shm"} { log.Printf("WAL mode failed: %v", err)
if err := os.Chmod(path+suffix, 0600); err != nil && !os.IsNotExist(err) {
slog.Warn("failed to chmod database file", "path", path+suffix, "err", err)
}
}
} }
return s, nil return s, nil
} }
func (d *SQLiteDialect) DriverName() string { return "sqlite" } func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
func (d *SQLiteDialect) BoolFalse() string { return "0" } func (d *SQLiteDialect) BoolFalse() string { return "0" }
func (d *SQLiteDialect) BaselineVersion() int { return 13 }
func (d *SQLiteDialect) CreateTablesSQL() []string { func (d *SQLiteDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -56,8 +39,7 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0, ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0
regions TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -67,8 +49,7 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL, latency_ns INTEGER, site_id INTEGER NOT NULL, latency_ns INTEGER,
is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
node_id TEXT DEFAULT ''
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
@@ -107,32 +88,24 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
`CREATE TABLE IF NOT EXISTS alert_health (
alert_id INTEGER PRIMARY KEY,
last_send_at DATETIME,
last_send_ok BOOLEAN DEFAULT 0,
last_error TEXT DEFAULT '',
send_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0
)`,
} }
} }
func (d *SQLiteDialect) Migrations() []Migration { func (d *SQLiteDialect) MigrationsSQL() []string {
return []Migration{ return []string{
{1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''",
{2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0",
{3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0",
{4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"}, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'",
{5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''",
{6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0",
{7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"}, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'",
{8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''",
{9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''",
{10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0",
{11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0",
{12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"}, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''",
{13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''",
} }
} }
@@ -140,56 +113,40 @@ func (d *SQLiteDialect) UpsertNodeSQL() string {
return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)" return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)"
} }
func (d *SQLiteDialect) UpsertAlertHealthSQL() string {
return "INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)"
}
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
var count int var count int
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
if count == 0 { if count == 0 {
if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil { if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil {
slog.Debug("sequence cleanup failed", "table", table, "err", err) log.Printf("sequence cleanup error: %v", err)
} }
} }
} }
func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) { func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
if _, err := tx.Exec("DELETE FROM sites"); err != nil { if _, err := tx.Exec("DELETE FROM sites"); err != nil {
slog.Debug("import wipe failed", "table", "sites", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(sites)", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM alerts"); err != nil { if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
slog.Debug("import wipe failed", "table", "alerts", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(alerts)", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil {
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(maintenance_windows)", "err", err)
}
if _, err := tx.Exec("DELETE FROM check_history"); err != nil {
slog.Debug("import wipe failed", "table", "check_history", "err", err)
}
if _, err := tx.Exec("DELETE FROM state_changes"); err != nil {
slog.Debug("import wipe failed", "table", "state_changes", "err", err)
}
if _, err := tx.Exec("DELETE FROM alert_health"); err != nil {
slog.Debug("import wipe failed", "table", "alert_health", "err", err)
}
}
func (d *SQLiteDialect) ImportWipeUsers(tx *sql.Tx) {
if _, err := tx.Exec("DELETE FROM users"); err != nil { if _, err := tx.Exec("DELETE FROM users"); err != nil {
slog.Debug("import wipe failed", "table", "users", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(users)", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil {
log.Printf("import wipe error: %v", err)
} }
} }
+345 -167
View File
@@ -1,26 +1,22 @@
package store package store
import ( import (
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
const ( const (
maxCheckHistory = 1000 maxCheckHistory = 1000
maxLogRows = 200 checkHistoryPruneAt = 1100
maxStateChangesPerSite = 5000
maxMaintenanceExport = 1000 maxMaintenanceExport = 1000
maxOpenConns = 25 maxRequestBody = 1 << 20
maxIdleConns = 5
connMaxLifetime = 5 * time.Minute
tokenByteLen = 16
) )
type SQLStore struct { type SQLStore struct {
@@ -35,9 +31,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetMaxOpenConns(maxOpenConns) db.SetMaxOpenConns(25)
db.SetMaxIdleConns(maxIdleConns) db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(connMaxLifetime) db.SetConnMaxLifetime(5 * time.Minute)
_, isDollar := dialect.(*PostgresDialect) _, isDollar := dialect.(*PostgresDialect)
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
} }
@@ -65,7 +61,7 @@ func (s *SQLStore) q(query string) string {
} }
func generateToken() (string, error) { func generateToken() (string, error) {
b := make([]byte, tokenByteLen) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("crypto/rand failed: %w", err) return "", fmt.Errorf("crypto/rand failed: %w", err)
} }
@@ -76,59 +72,38 @@ func (s *SQLStore) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *SQLStore) Init(ctx context.Context) error { func (s *SQLStore) Init() error {
for _, stmt := range s.dialect.CreateTablesSQL() { for _, stmt := range s.dialect.CreateTablesSQL() {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { if _, err := s.db.Exec(stmt); err != nil {
return err return err
} }
} }
for _, m := range s.dialect.MigrationsSQL() {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version ( if _, err := s.db.Exec(m); err != nil {
version INTEGER PRIMARY KEY, errMsg := err.Error()
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
)`); err != nil {
return fmt.Errorf("create schema_version: %w", err)
}
var current int
_ = s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&current) //nolint:errcheck
if current == 0 {
baseline := s.dialect.BaselineVersion()
if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), baseline); err != nil {
return fmt.Errorf("seed baseline version: %w", err)
}
current = baseline
}
for _, m := range s.dialect.Migrations() {
if m.Version <= current {
continue continue
} }
if _, err := s.db.ExecContext(ctx, m.SQL); err != nil { return fmt.Errorf("migration failed: %w", err)
return fmt.Errorf("migration %d failed: %w", m.Version, err)
}
if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), m.Version); err != nil {
return fmt.Errorf("record migration %d: %w", m.Version, err)
} }
} }
return nil return nil
} }
func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) { func (s *SQLStore) GetSites() ([]models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites", "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites",
bf, bf, bf, bf,
) )
rows, err := s.db.QueryContext(ctx, query) rows, err := s.db.Query(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var sites []models.SiteConfig var sites []models.Site
for rows.Next() { for rows.Next() {
var st models.SiteConfig var st models.Site
if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
@@ -140,7 +115,7 @@ func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) {
return sites, rows.Err() return sites, rows.Err()
} }
func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error { func (s *SQLStore) AddSite(site models.Site) error {
token := "" token := ""
if site.Type == "push" { if site.Type == "push" {
var err error var err error
@@ -149,17 +124,15 @@ func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error {
return fmt.Errorf("generate push token: %w", err) return fmt.Errorf("generate push token: %w", err)
} }
} }
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), _, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
return err return err
} }
func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error { func (s *SQLStore) UpdateSite(site models.Site) error {
var existingToken string var existingToken string
if err := s.db.QueryRowContext(ctx, s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken); err != nil && err != sql.ErrNoRows { _ = s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck
return fmt.Errorf("read existing token: %w", err)
}
if site.Type == "push" && existingToken == "" { if site.Type == "push" && existingToken == "" {
var err error var err error
existingToken, err = generateToken() existingToken, err = generateToken()
@@ -167,57 +140,75 @@ func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error
return fmt.Errorf("generate push token: %w", err) return fmt.Errorf("generate push token: %w", err)
} }
} }
_, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"), _, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"),
site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID)
return err return err
} }
func (s *SQLStore) UpdateSitePaused(ctx context.Context, id int, paused bool) error { func (s *SQLStore) UpdateSitePaused(id int, paused bool) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id) _, err := s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id)
return err return err
} }
func (s *SQLStore) DeleteSite(ctx context.Context, id int) error { func (s *SQLStore) DeleteSite(id int) error {
tx, err := s.db.BeginTx(ctx, nil) _, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = tx.Rollback() }()
for _, q := range []string{
"DELETE FROM maintenance_windows WHERE monitor_id = ?",
"DELETE FROM check_history WHERE site_id = ?",
"DELETE FROM state_changes WHERE site_id = ?",
"DELETE FROM sites WHERE id = ?",
} {
if _, err := tx.ExecContext(ctx, s.q(q), id); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "sites") s.dialect.ResetSequenceOnEmpty(s.db, "sites")
return nil return nil
} }
func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s", "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s",
bf, bf, s.q("?"), bf, bf, s.q("?"),
) )
var st models.SiteConfig var st models.Site
err := s.db.QueryRowContext(ctx, query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, err := s.db.QueryRow(query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
&st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions) &st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions)
return st, err return st, err
} }
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) { func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
}
return a, nil
}
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
token := "" token := ""
if site.Type == "push" { if site.Type == "push" {
var err error var err error
@@ -228,12 +219,12 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
} }
if s.dollar { if s.dollar {
var id int var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"), err := s.db.QueryRow(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id)
return id, err return id, err
} }
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), result, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
if err != nil { if err != nil {
@@ -243,8 +234,89 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
return int(id), err return int(id), err
} }
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) { func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users") stored, err := s.marshalSettings(settings)
if err != nil {
return 0, err
}
if s.dollar {
var id int
err := s.db.QueryRow(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err
}
result, err := s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
}
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
return alerts, rows.Err()
}
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
}
return a, nil
}
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err
}
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err
}
func (s *SQLStore) DeleteAlert(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil
}
func (s *SQLStore) GetAllUsers() ([]models.User, error) {
rows, err := s.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -260,35 +332,79 @@ func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
return users, rows.Err() return users, rows.Err()
} }
func (s *SQLStore) AddUser(ctx context.Context, username, publicKey, role string) error { func (s *SQLStore) AddUser(username, publicKey, role string) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role) _, err := s.db.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role)
return err return err
} }
func (s *SQLStore) UpdateUser(ctx context.Context, id int, username, publicKey, role string) error { func (s *SQLStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id) _, err := s.db.Exec(s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id)
return err return err
} }
func (s *SQLStore) DeleteUser(ctx context.Context, id int) error { func (s *SQLStore) DeleteUser(id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM users WHERE id=?"), id) _, err := s.db.Exec(s.q("DELETE FROM users WHERE id=?"), id)
return err return err
} }
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error { func (s *SQLStore) SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error {
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version) _, err := s.db.Exec(s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
siteID, fromStatus, toStatus, errorReason)
return err return err
} }
func (s *SQLStore) GetNode(ctx context.Context, id string) (models.ProbeNode, error) { func (s *SQLStore) GetStateChanges(siteID int, limit int) ([]models.StateChange, error) {
rows, err := s.db.Query(s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(siteID, "", latencyNs, isUp)
}
func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
_, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
if err != nil {
return err
}
var count int
_ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
if count > checkHistoryPruneAt {
pruneQuery := fmt.Sprintf(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT %d
)`, maxCheckHistory)
_, err = s.db.Exec(s.q(pruneQuery), siteID, siteID)
return err
}
return nil
}
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
_, err := s.db.Exec(s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
return err
}
func (s *SQLStore) GetNode(id string) (models.ProbeNode, error) {
var n models.ProbeNode var n models.ProbeNode
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id). err := s.db.QueryRow(s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id).
Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version) Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version)
return n, err return n, err
} }
func (s *SQLStore) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) { func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name") rows, err := s.db.Query("SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -304,127 +420,196 @@ func (s *SQLStore) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error)
return nodes, rows.Err() return nodes, rows.Err()
} }
func (s *SQLStore) UpdateNodeLastSeen(ctx context.Context, id string) error { func (s *SQLStore) UpdateNodeLastSeen(id string) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id) _, err := s.db.Exec(s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err return err
} }
func (s *SQLStore) DeleteNode(ctx context.Context, id string) error { func (s *SQLStore) DeleteNode(id string) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM nodes WHERE id = ?"), id) _, err := s.db.Exec(s.q("DELETE FROM nodes WHERE id = ?"), id)
return err return err
} }
func (s *SQLStore) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) { func (s *SQLStore) SaveLog(message string) error {
rows, err := s.db.QueryContext(ctx, "SELECT alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count FROM alert_health") _, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`DELETE FROM logs WHERE id NOT IN (
SELECT id FROM logs ORDER BY created_at DESC LIMIT 200
)`))
return err
}
func (s *SQLStore) LoadLogs(limit int) ([]string, error) {
rows, err := s.db.Query(s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
out := make(map[int]models.AlertHealthRecord) var logs []string
for rows.Next() { for rows.Next() {
var r models.AlertHealthRecord var msg string
var lastSend sql.NullTime if err := rows.Scan(&msg); err != nil {
if err := rows.Scan(&r.AlertID, &lastSend, &r.LastSendOK, &r.LastError, &r.SendCount, &r.FailCount); err != nil { return logs, err
return out, err
} }
if lastSend.Valid { logs = append(logs, msg)
r.LastSendAt = lastSend.Time
} }
out[r.AlertID] = r return logs, rows.Err()
}
return out, rows.Err()
} }
func (s *SQLStore) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error { func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) {
var lastSend interface{} result := make(map[int][]models.CheckRecord)
if !h.LastSendAt.IsZero() { rows, err := s.db.Query(s.q(`
lastSend = h.LastSendAt SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= ?`), limit)
if err != nil {
return result, err
} }
_, err := s.db.ExecContext(ctx, s.dialect.UpsertAlertHealthSQL(), defer rows.Close()
h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount) for rows.Next() {
return err var r models.CheckRecord
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
return result, err
}
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result, rows.Err()
} }
// SaveLog inserts a single log row. Retention is handled by PruneLogs on a func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
// timer, not per-insert. var mw models.MaintenanceWindow
func (s *SQLStore) SaveLog(ctx context.Context, message string) error { var endTime sql.NullTime
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO logs (message) VALUES (?)"), message) if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
return err return mw, err
}
if endTime.Valid {
mw.EndTime = endTime.Time
}
return mw, nil
} }
// PruneLogs trims the logs table to the newest maxLogRows rows. The id DESC func (s *SQLStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
// tiebreak keeps ordering deterministic when rows share a created_at second. rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
func (s *SQLStore) PruneLogs(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM logs WHERE id NOT IN (
SELECT id FROM logs ORDER BY created_at DESC, id DESC LIMIT %d
)`, maxLogRows)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var entries []models.LogEntry var windows []models.MaintenanceWindow
for rows.Next() { for rows.Next() {
var e models.LogEntry mw, err := s.scanMaintenanceWindow(rows)
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil { if err != nil {
return entries, err return windows, err
} }
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries. windows = append(windows, mw)
if len(e.Message) > 3 && e.Message[0] == '[' {
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
e.Message = strings.TrimSpace(e.Message[idx+1:])
} }
} return windows, rows.Err()
entries = append(entries, e)
}
return entries, rows.Err()
} }
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) { func (s *SQLStore) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error) {
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) AddMaintenanceWindow(mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() {
mw.StartTime = time.Now()
}
_, err := s.db.Exec(s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err
}
func (s *SQLStore) EndMaintenanceWindow(id int) error {
_, err := s.db.Exec(s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteMaintenanceWindow(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
return nil
}
func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
var count int
err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
AND (monitor_id = 0 OR monitor_id = ?
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
monitorID, monitorID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *SQLStore) GetPreference(key string) (string, error) {
var value string var value string
err := s.db.QueryRowContext(ctx, s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value) err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
if err != nil { if err != nil {
return "", err return "", err
} }
return value, nil return value, nil
} }
func (s *SQLStore) SetPreference(ctx context.Context, key, value string) error { func (s *SQLStore) SetPreference(key, value string) error {
if s.dollar { if s.dollar {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value) _, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
return err return err
} }
_, err := s.db.ExecContext(ctx, "INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value) _, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
return err return err
} }
func (s *SQLStore) ExportData(ctx context.Context) (models.Backup, error) { func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites(ctx) sites, err := s.GetSites()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
alerts, err := s.GetAllAlerts(ctx) alerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
users, err := s.GetAllUsers(ctx) users, err := s.GetAllUsers()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
windows, err := s.GetAllMaintenanceWindows(ctx, maxMaintenanceExport) windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
} }
func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error { func (s *SQLStore) ImportData(data models.Backup) error {
tx, err := s.db.BeginTx(ctx, nil) tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
} }
@@ -432,29 +617,22 @@ func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error {
s.dialect.ImportWipe(tx) s.dialect.ImportWipe(tx)
// Only wipe+replace users when callers explicitly provide them (CLI
// full restore). API/Kuma imports pass nil — existing users preserved.
if data.Users != nil {
s.dialect.ImportWipeUsers(tx)
for _, u := range data.Users { for _, u := range data.Users {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil { if _, err := tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil {
return err return err
} }
} }
}
for _, a := range data.Alerts { for _, a := range data.Alerts {
// Encrypt on import exactly as AddAlert/UpdateAlert do, so a restore jsonBytes, err := json.Marshal(a.Settings)
// honors UPTOP_ENCRYPTION_KEY instead of writing secrets in plaintext.
settingsStr, err := s.marshalSettings(a.Settings)
if err != nil { if err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, settingsStr); err != nil { if _, err := tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)); err != nil {
return err return err
} }
} }
for _, st := range data.Sites { for _, st := range data.Sites {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries, st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil { st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil {
return err return err
@@ -462,7 +640,7 @@ func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error {
} }
for _, mw := range data.MaintenanceWindows { for _, mw := range data.MaintenanceWindows {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"), if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil { mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil {
return err return err
} }
-126
View File
@@ -1,126 +0,0 @@
package store
import (
"context"
"encoding/json"
"fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
}
return a, nil
}
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
stored, err := s.marshalSettings(settings)
if err != nil {
return 0, err
}
if s.dollar {
var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err
}
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
}
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
return alerts, rows.Err()
}
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsRaw string
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
}
return a, nil
}
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err
}
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err
}
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil
}
-102
View File
@@ -1,102 +0,0 @@
package store
import (
"context"
"strings"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestAlertCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Type != "discord" {
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
}
if alerts[0].Settings["url"] != "https://example.com/hook" {
t.Errorf("settings url mismatch")
}
a, err := s.GetAlert(context.Background(), alerts[0].ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Name != "Discord" {
t.Errorf("expected name 'Discord', got '%s'", a.Name)
}
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err)
}
a, err = s.GetAlert(context.Background(), a.ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type)
}
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
alerts, err = s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
}
}
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
func TestImportData_EncryptsAlertSettings(t *testing.T) {
s := newTestStore(t)
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
s.SetEncryptor(enc)
backup := models.Backup{
Alerts: []models.AlertConfig{
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
},
}
if err := s.ImportData(context.Background(), backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
var raw string
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
t.Fatalf("query settings: %v", err)
}
if !strings.HasPrefix(raw, encryptedPrefix) {
t.Errorf("imported settings not encrypted: %q", raw)
}
if strings.Contains(raw, "SECRET") {
t.Errorf("plaintext secret found in stored column: %q", raw)
}
alerts, err := s.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
t.Errorf("decrypt round-trip failed: %+v", alerts)
}
}
-116
View File
@@ -1,116 +0,0 @@
package store
import (
"context"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
siteID, fromStatus, toStatus, errorReason)
return err
}
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
if err != nil {
return nil, err
}
defer rows.Close()
var changes []models.StateChange
for rows.Next() {
var sc models.StateChange
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
return changes, err
}
changes = append(changes, sc)
}
return changes, rows.Err()
}
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
}
// SaveCheckFromNode inserts a single check row. Retention is handled out of
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
// path a plain INSERT.
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
return err
}
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
// site, across all sites, in one pass. Intended to run periodically.
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
FROM check_history
) ranked WHERE rn > %d
)`, maxCheckHistory)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
// otherwise unbounded growth of a flapping monitor's history.
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
FROM state_changes
) ranked WHERE rn > %d
)`, maxStateChangesPerSite)
_, err := s.db.ExecContext(ctx, s.q(q))
return err
}
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
result := make(map[int][]models.CheckRecord)
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= ?`), limit)
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
return result, err
}
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result, rows.Err()
}
-134
View File
@@ -1,134 +0,0 @@
package store
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestCheckHistory(t *testing.T) {
s := newTestStore(t)
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 10)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != 2 {
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
}
if len(history[2]) != 1 {
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
}
upCount := 0
for _, r := range history[1] {
if r.IsUp {
upCount++
}
}
if upCount != 1 {
t.Errorf("expected 1 up record for site 1, got %d", upCount)
}
}
func TestPruneCheckHistory(t *testing.T) {
s := newTestStore(t)
for i := 0; i < maxCheckHistory+5; i++ {
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 1: %v", err)
}
}
for i := 0; i < 3; i++ {
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
}
if err := s.PruneCheckHistory(context.Background()); err != nil {
t.Fatalf("PruneCheckHistory: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != maxCheckHistory {
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
}
if len(history[2]) != 3 {
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
}
}
func TestDeleteSiteCascade(t *testing.T) {
s := newTestStore(t)
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
if err := s.AddSite(context.Background(), site); err != nil {
t.Fatalf("AddSite: %v", err)
}
sites, err := s.GetSites(context.Background())
if err != nil {
t.Fatalf("GetSites: %v", err)
}
siteID := sites[0].ID
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
t.Fatalf("SaveStateChange: %v", err)
}
mw := models.MaintenanceWindow{
MonitorID: siteID,
Title: "Test MW",
Type: "maintenance",
StartTime: time.Now(),
}
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
if err := s.DeleteSite(context.Background(), siteID); err != nil {
t.Fatalf("DeleteSite: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 100)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[siteID]) != 0 {
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
}
changes, err := s.GetStateChanges(context.Background(), siteID, 100)
if err != nil {
t.Fatalf("GetStateChanges: %v", err)
}
if len(changes) != 0 {
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
}
windows, err := s.GetActiveMaintenanceWindows(context.Background())
if err != nil {
t.Fatalf("GetActiveMaintenanceWindows: %v", err)
}
for _, w := range windows {
if w.MonitorID == siteID {
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
}
}
}
-148
View File
@@ -1,148 +0,0 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
var mw models.MaintenanceWindow
var endTime sql.NullTime
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
return mw, err
}
if endTime.Valid {
mw.EndTime = endTime.Time
}
return mw, nil
}
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return windows, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
var timeClause string
var args []interface{}
if endTime.IsZero() {
timeClause = "(end_time IS NULL OR end_time > ?)"
args = append(args, startTime)
} else {
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
args = append(args, startTime, endTime)
}
var scopeClause string
if monitorID == 0 {
scopeClause = "1=1"
} else {
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
args = append(args, monitorID, monitorID)
}
query := fmt.Sprintf(
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
timeClause, scopeClause,
)
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var windows []models.MaintenanceWindow
for rows.Next() {
mw, err := s.scanMaintenanceWindow(rows)
if err != nil {
return nil, err
}
windows = append(windows, mw)
}
return windows, rows.Err()
}
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() {
mw.StartTime = time.Now()
}
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err
}
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
return nil
}
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
cutoff := time.Now().Add(-retention)
result, err := s.db.ExecContext(ctx,
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
cutoff,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
AND (monitor_id = 0 OR monitor_id = ?
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
monitorID, monitorID).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
-168
View File
@@ -1,168 +0,0 @@
package store
import (
"context"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
s := newTestStore(t)
now := time.Now()
// Expired 10 days ago — should be pruned with 7d retention.
old := models.MaintenanceWindow{
MonitorID: 0,
Title: "Old Window",
Type: "maintenance",
StartTime: now.Add(-11 * 24 * time.Hour),
EndTime: now.Add(-10 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
t.Fatalf("AddMaintenanceWindow (old): %v", err)
}
// Expired 1 day ago — within 7d retention, should survive.
recent := models.MaintenanceWindow{
MonitorID: 0,
Title: "Recent Window",
Type: "maintenance",
StartTime: now.Add(-2 * 24 * time.Hour),
EndTime: now.Add(-1 * 24 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
}
// Ongoing — no end time, should survive.
ongoing := models.MaintenanceWindow{
MonitorID: 0,
Title: "Ongoing Window",
Type: "maintenance",
StartTime: now.Add(-1 * time.Hour),
}
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
}
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
if err != nil {
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
}
if pruned != 1 {
t.Errorf("expected 1 pruned, got %d", pruned)
}
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
if err != nil {
t.Fatalf("GetAllMaintenanceWindows: %v", err)
}
if len(all) != 2 {
t.Fatalf("expected 2 remaining windows, got %d", len(all))
}
for _, w := range all {
if w.Title == "Old Window" {
t.Error("old window should have been pruned")
}
}
}
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
if err := s.AddSite(ctx, site); err != nil {
t.Fatalf("AddSite: %v", err)
}
now := time.Now()
active := models.MaintenanceWindow{
MonitorID: 1,
Title: "Deploy v2",
Type: "maintenance",
StartTime: now.Add(-30 * time.Minute),
EndTime: now.Add(30 * time.Minute),
}
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
ended := models.MaintenanceWindow{
MonitorID: 1,
Title: "Old deploy",
Type: "maintenance",
StartTime: now.Add(-3 * time.Hour),
EndTime: now.Add(-2 * time.Hour),
}
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
t.Run("same monitor overlaps", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 1 {
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
}
if overlaps[0].Title != "Deploy v2" {
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
}
})
t.Run("different monitor no overlap", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 0 {
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
}
})
t.Run("global window overlaps all", func(t *testing.T) {
global := models.MaintenanceWindow{
MonitorID: 0,
Title: "Global freeze",
Type: "maintenance",
StartTime: now.Add(-10 * time.Minute),
EndTime: now.Add(2 * time.Hour),
}
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
t.Fatalf("AddMaintenanceWindow: %v", err)
}
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 2 {
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
}
})
t.Run("indefinite window overlaps", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) < 1 {
t.Error("expected at least 1 overlap for indefinite window")
}
})
t.Run("ended window excluded", func(t *testing.T) {
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(overlaps) != 0 {
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
}
})
}
+91 -139
View File
@@ -1,11 +1,8 @@
package store package store
import ( import (
"context" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"fmt"
"testing" "testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
func newTestStore(t *testing.T) *SQLStore { func newTestStore(t *testing.T) *SQLStore {
@@ -14,7 +11,7 @@ func newTestStore(t *testing.T) *SQLStore {
if err != nil { if err != nil {
t.Fatalf("NewSQLiteStore: %v", err) t.Fatalf("NewSQLiteStore: %v", err)
} }
if err := s.Init(context.Background()); err != nil { if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err) t.Fatalf("Init: %v", err)
} }
return s return s
@@ -23,7 +20,7 @@ func newTestStore(t *testing.T) *SQLStore {
func TestSiteCRUD(t *testing.T) { func TestSiteCRUD(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
sites, err := s.GetSites(context.Background()) sites, err := s.GetSites()
if err != nil { if err != nil {
t.Fatalf("GetSites: %v", err) t.Fatalf("GetSites: %v", err)
} }
@@ -31,11 +28,11 @@ func TestSiteCRUD(t *testing.T) {
t.Fatalf("expected 0 sites, got %d", len(sites)) t.Fatalf("expected 0 sites, got %d", len(sites))
} }
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { if err := s.AddSite(models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
t.Fatalf("AddSite: %v", err) t.Fatalf("AddSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, err = s.GetSites()
if err != nil { if err != nil {
t.Fatalf("GetSites: %v", err) t.Fatalf("GetSites: %v", err)
} }
@@ -47,39 +44,81 @@ func TestSiteCRUD(t *testing.T) {
} }
sites[0].Name = "Updated" sites[0].Name = "Updated"
if err := s.UpdateSite(context.Background(), sites[0]); err != nil { if err := s.UpdateSite(sites[0]); err != nil {
t.Fatalf("UpdateSite: %v", err) t.Fatalf("UpdateSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, _ = s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if sites[0].Name != "Updated" { if sites[0].Name != "Updated" {
t.Errorf("expected name 'Updated', got '%s'", sites[0].Name) t.Errorf("expected name 'Updated', got '%s'", sites[0].Name)
} }
if err := s.DeleteSite(context.Background(), sites[0].ID); err != nil { if err := s.DeleteSite(sites[0].ID); err != nil {
t.Fatalf("DeleteSite: %v", err) t.Fatalf("DeleteSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, _ = s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 0 { if len(sites) != 0 {
t.Fatalf("expected 0 sites after delete, got %d", len(sites)) t.Fatalf("expected 0 sites after delete, got %d", len(sites))
} }
} }
func TestAlertCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err)
}
alerts, err := s.GetAllAlerts()
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Type != "discord" {
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
}
if alerts[0].Settings["url"] != "https://example.com/hook" {
t.Errorf("settings url mismatch")
}
a, err := s.GetAlert(alerts[0].ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Name != "Discord" {
t.Errorf("expected name 'Discord', got '%s'", a.Name)
}
if err := s.UpdateAlert(a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err)
}
a, _ = s.GetAlert(a.ID)
if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type)
}
if err := s.DeleteAlert(a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
alerts, _ = s.GetAllAlerts()
if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
}
}
func TestUserCRUD(t *testing.T) { func TestUserCRUD(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 AAAA...", "admin"); err != nil { if err := s.AddUser("admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
t.Fatalf("AddUser: %v", err) t.Fatalf("AddUser: %v", err)
} }
users, err := s.GetAllUsers(context.Background()) users, err := s.GetAllUsers()
if err != nil { if err != nil {
t.Fatalf("GetAllUsers: %v", err) t.Fatalf("GetAllUsers: %v", err)
} }
@@ -90,26 +129,20 @@ func TestUserCRUD(t *testing.T) {
t.Errorf("expected username 'admin', got '%s'", users[0].Username) t.Errorf("expected username 'admin', got '%s'", users[0].Username)
} }
if err := s.UpdateUser(context.Background(), users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil { if err := s.UpdateUser(users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
t.Fatalf("UpdateUser: %v", err) t.Fatalf("UpdateUser: %v", err)
} }
users, err = s.GetAllUsers(context.Background()) users, _ = s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if users[0].Username != "root" { if users[0].Username != "root" {
t.Errorf("expected username 'root', got '%s'", users[0].Username) t.Errorf("expected username 'root', got '%s'", users[0].Username)
} }
if err := s.DeleteUser(context.Background(), users[0].ID); err != nil { if err := s.DeleteUser(users[0].ID); err != nil {
t.Fatalf("DeleteUser: %v", err) t.Fatalf("DeleteUser: %v", err)
} }
users, err = s.GetAllUsers(context.Background()) users, _ = s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(users) != 0 { if len(users) != 0 {
t.Fatalf("expected 0 users after delete, got %d", len(users)) t.Fatalf("expected 0 users after delete, got %d", len(users))
} }
@@ -118,14 +151,11 @@ func TestUserCRUD(t *testing.T) {
func TestPushTokenGeneration(t *testing.T) { func TestPushTokenGeneration(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil { if err := s.AddSite(models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
t.Fatalf("AddSite: %v", err) t.Fatalf("AddSite: %v", err)
} }
sites, err := s.GetSites(context.Background()) sites, _ := s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 1 { if len(sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(sites)) t.Fatalf("expected 1 site, got %d", len(sites))
} }
@@ -140,17 +170,11 @@ func TestPushTokenGeneration(t *testing.T) {
func TestImportExport(t *testing.T) { func TestImportExport(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Test Alert", "webhook", map[string]string{"url": "https://example.com"}); err != nil { s.AddAlert("Test Alert", "webhook", map[string]string{"url": "https://example.com"})
t.Fatalf("AddAlert: %v", err) s.AddSite(models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30})
} s.AddUser("user1", "ssh-ed25519 KEY", "user")
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
t.Fatalf("AddSite: %v", err)
}
if err := s.AddUser(context.Background(), "user1", "ssh-ed25519 KEY", "user"); err != nil {
t.Fatalf("AddUser: %v", err)
}
backup, err := s.ExportData(context.Background()) backup, err := s.ExportData()
if err != nil { if err != nil {
t.Fatalf("ExportData: %v", err) t.Fatalf("ExportData: %v", err)
} }
@@ -159,121 +183,49 @@ func TestImportExport(t *testing.T) {
} }
s2 := newTestStore(t) s2 := newTestStore(t)
if err := s2.ImportData(context.Background(), backup); err != nil { if err := s2.ImportData(backup); err != nil {
t.Fatalf("ImportData: %v", err) t.Fatalf("ImportData: %v", err)
} }
sites, err := s2.GetSites(context.Background()) sites, _ := s2.GetSites()
if err != nil { alerts, _ := s2.GetAllAlerts()
t.Fatalf("GetSites: %v", err) users, _ := s2.GetAllUsers()
}
alerts, err := s2.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
users, err := s2.GetAllUsers(context.Background())
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 { if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 {
t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users)) t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users))
} }
} }
func TestImportData_WipesHistory(t *testing.T) { func TestCheckHistory(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "OldSite", URL: "https://old.com", Type: "http", Interval: 30}); err != nil { if err := s.SaveCheck(1, 5000000, true); err != nil {
t.Fatalf("AddSite: %v", err)
}
if err := s.SaveCheck(context.Background(), 1, 5000, true); err != nil {
t.Fatalf("SaveCheck: %v", err) t.Fatalf("SaveCheck: %v", err)
} }
if err := s.SaveStateChange(context.Background(), 1, "UP", "DOWN", "timeout"); err != nil { if err := s.SaveCheck(1, 10000000, false); err != nil {
t.Fatalf("SaveStateChange: %v", err) t.Fatalf("SaveCheck: %v", err)
} }
if err := s.SaveAlertHealth(context.Background(), models.AlertHealthRecord{AlertID: 1, LastSendOK: true, SendCount: 1}); err != nil { if err := s.SaveCheck(2, 3000000, true); err != nil {
t.Fatalf("SaveAlertHealth: %v", err) t.Fatalf("SaveCheck site 2: %v", err)
} }
backup := models.Backup{ history, err := s.LoadAllHistory(10)
Sites: []models.SiteConfig{{ID: 1, Name: "NewSite", URL: "https://new.com", Type: "http", Interval: 60}},
}
if err := s.ImportData(context.Background(), backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
history, err := s.LoadAllHistory(context.Background(), 100)
if err != nil { if err != nil {
t.Fatalf("LoadAllHistory: %v", err) t.Fatalf("LoadAllHistory: %v", err)
} }
if len(history) != 0 { if len(history[1]) != 2 {
t.Errorf("expected empty check_history after import, got %d sites with history", len(history)) t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
}
if len(history[2]) != 1 {
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
} }
changes, err := s.GetStateChanges(context.Background(), 1, 100) upCount := 0
if err != nil { for _, r := range history[1] {
t.Fatalf("GetStateChanges: %v", err) if r.IsUp {
} upCount++
if len(changes) != 0 {
t.Errorf("expected empty state_changes after import, got %d", len(changes))
} }
} }
if upCount != 1 {
func TestImportData_NilUsersPreservesExisting(t *testing.T) { t.Errorf("expected 1 up record for site 1, got %d", upCount)
s := newTestStore(t)
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 ADMINKEY", "admin"); err != nil {
t.Fatalf("AddUser: %v", err)
}
backup := models.Backup{
Sites: []models.SiteConfig{{ID: 1, Name: "New", URL: "https://new.com", Type: "http", Interval: 30}},
Alerts: []models.AlertConfig{{ID: 1, Name: "a", Type: "webhook", Settings: map[string]string{"url": "https://h.com"}}},
Users: nil,
}
if err := s.ImportData(context.Background(), backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
users, err := s.GetAllUsers(context.Background())
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(users) != 1 || users[0].Username != "admin" {
t.Errorf("expected existing admin user preserved, got %d users", len(users))
}
}
func TestPruneLogs(t *testing.T) {
s := newTestStore(t)
for i := 0; i < maxLogRows+50; i++ {
if err := s.SaveLog(context.Background(), fmt.Sprintf("log %d", i)); err != nil {
t.Fatalf("SaveLog: %v", err)
}
}
if err := s.PruneLogs(context.Background()); err != nil {
t.Fatalf("PruneLogs: %v", err)
}
logs, err := s.LoadLogs(context.Background(), maxLogRows*2)
if err != nil {
t.Fatalf("LoadLogs: %v", err)
}
if len(logs) != maxLogRows {
t.Errorf("expected %d logs after prune, got %d", maxLogRows, len(logs))
}
// Newest must survive; oldest must be gone (membership, not position —
// LoadLogs ordering ties when rows share a created_at second).
present := make(map[string]bool, len(logs))
for _, l := range logs {
present[l.Message] = true
}
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
t.Error("newest log was pruned")
}
if present["log 0"] {
t.Error("oldest log survived prune")
} }
} }
+42 -55
View File
@@ -1,86 +1,73 @@
package store package store
import ( import (
"context" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type Store interface { type Store interface {
Init(ctx context.Context) error Init() error
// Sites // Sites
GetSites(ctx context.Context) ([]models.SiteConfig, error) GetSites() ([]models.Site, error)
AddSite(ctx context.Context, site models.SiteConfig) error AddSite(site models.Site) error
UpdateSite(ctx context.Context, site models.SiteConfig) error UpdateSite(site models.Site) error
UpdateSitePaused(ctx context.Context, id int, paused bool) error UpdateSitePaused(id int, paused bool) error
DeleteSite(ctx context.Context, id int) error DeleteSite(id int) error
// Alerts // Alerts
GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) GetAllAlerts() ([]models.AlertConfig, error)
GetAlert(ctx context.Context, id int) (models.AlertConfig, error) GetAlert(id int) (models.AlertConfig, error)
AddAlert(ctx context.Context, name, aType string, settings map[string]string) error AddAlert(name, aType string, settings map[string]string) error
UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error UpdateAlert(id int, name, aType string, settings map[string]string) error
DeleteAlert(ctx context.Context, id int) error DeleteAlert(id int) error
// Declarative config support // Declarative config support
GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) GetSiteByName(name string) (models.Site, error)
GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) GetAlertByName(name string) (models.AlertConfig, error)
AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) AddSiteReturningID(site models.Site) (int, error)
AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) AddAlertReturningID(name, aType string, settings map[string]string) (int, error)
// Users // Users
GetAllUsers(ctx context.Context) ([]models.User, error) GetAllUsers() ([]models.User, error)
AddUser(ctx context.Context, username, publicKey, role string) error AddUser(username, publicKey, role string) error
UpdateUser(ctx context.Context, id int, username, publicKey, role string) error UpdateUser(id int, username, publicKey, role string) error
DeleteUser(ctx context.Context, id int) error DeleteUser(id int) error
// History // History
SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error SaveCheck(siteID int, latencyNs int64, isUp bool) error
SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
PruneCheckHistory(ctx context.Context) error
// State Changes // State Changes
SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error
GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) GetStateChanges(siteID int, limit int) ([]models.StateChange, error)
GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error)
PruneStateChanges(ctx context.Context) error
// Nodes // Nodes
RegisterNode(ctx context.Context, node models.ProbeNode) error RegisterNode(node models.ProbeNode) error
GetNode(ctx context.Context, id string) (models.ProbeNode, error) GetNode(id string) (models.ProbeNode, error)
GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) GetAllNodes() ([]models.ProbeNode, error)
UpdateNodeLastSeen(ctx context.Context, id string) error UpdateNodeLastSeen(id string) error
DeleteNode(ctx context.Context, id string) error DeleteNode(id string) error
// Alert Health
LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error)
SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error
// Logs // Logs
SaveLog(ctx context.Context, message string) error SaveLog(message string) error
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) LoadLogs(limit int) ([]string, error)
PruneLogs(ctx context.Context) error
// Maintenance Windows // Maintenance Windows
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) AddMaintenanceWindow(mw models.MaintenanceWindow) error
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error EndMaintenanceWindow(id int) error
EndMaintenanceWindow(ctx context.Context, id int) error DeleteMaintenanceWindow(id int) error
DeleteMaintenanceWindow(ctx context.Context, id int) error IsMonitorInMaintenance(monitorID int) (bool, error)
PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error)
IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error)
// Preferences // Preferences
GetPreference(ctx context.Context, key string) (string, error) GetPreference(key string) (string, error)
SetPreference(ctx context.Context, key, value string) error SetPreference(key, value string) error
// Backup & Restore // Backup & Restore
ExportData(ctx context.Context) (models.Backup, error) ExportData() (models.Backup, error)
ImportData(ctx context.Context, data models.Backup) error ImportData(data models.Backup) error
// Lifecycle // Lifecycle
Close() error Close() error
-280
View File
@@ -1,280 +0,0 @@
package storetest
import (
"context"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// BaseMock implements store.Store with no-op defaults. Embed it in test-specific
// mocks and override only the methods you need via the exported Func fields or
// by shadowing the method on the embedding struct.
type BaseMock struct {
GetSitesFunc func(ctx context.Context) ([]models.SiteConfig, error)
AddSiteFunc func(ctx context.Context, site models.SiteConfig) error
UpdateSiteFunc func(ctx context.Context, site models.SiteConfig) error
GetAllAlertsFunc func(ctx context.Context) ([]models.AlertConfig, error)
GetAlertFunc func(ctx context.Context, id int) (models.AlertConfig, error)
GetAllUsersFunc func(ctx context.Context) ([]models.User, error)
GetAllNodesFunc func(ctx context.Context) ([]models.ProbeNode, error)
GetActiveMaintenanceWindowsFunc func(ctx context.Context) ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindowsFunc func(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
IsMonitorInMaintenanceFunc func(ctx context.Context, id int) (bool, error)
LoadAlertHealthFunc func(ctx context.Context) (map[int]models.AlertHealthRecord, error)
LoadAllHistoryFunc func(ctx context.Context, limit int) (map[int][]models.CheckRecord, error)
SaveCheckFunc func(ctx context.Context, siteID int, latencyNs int64, isUp bool) error
SaveCheckFromNodeFunc func(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error
SaveLogFunc func(ctx context.Context, message string) error
SaveStateChangeFunc func(ctx context.Context, siteID int, from, to, reason string) error
SaveAlertHealthFunc func(ctx context.Context, h models.AlertHealthRecord) error
GetStateChangesFunc func(ctx context.Context, siteID, limit int) ([]models.StateChange, error)
GetStateChangesSinceFunc func(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error)
ExportDataFunc func(ctx context.Context) (models.Backup, error)
ImportDataFunc func(ctx context.Context, data models.Backup) error
RegisterNodeFunc func(ctx context.Context, node models.ProbeNode) error
GetNodeFunc func(ctx context.Context, id string) (models.ProbeNode, error)
GetPreferenceFunc func(ctx context.Context, key string) (string, error)
SetPreferenceFunc func(ctx context.Context, key, value string) error
}
func (m *BaseMock) Init(_ context.Context) error { return nil }
func (m *BaseMock) Close() error { return nil }
func (m *BaseMock) GetSites(ctx context.Context) ([]models.SiteConfig, error) {
if m.GetSitesFunc != nil {
return m.GetSitesFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) AddSite(ctx context.Context, site models.SiteConfig) error {
if m.AddSiteFunc != nil {
return m.AddSiteFunc(ctx, site)
}
return nil
}
func (m *BaseMock) UpdateSite(ctx context.Context, site models.SiteConfig) error {
if m.UpdateSiteFunc != nil {
return m.UpdateSiteFunc(ctx, site)
}
return nil
}
func (m *BaseMock) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil }
func (m *BaseMock) DeleteSite(_ context.Context, _ int) error { return nil }
func (m *BaseMock) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
if m.GetAllAlertsFunc != nil {
return m.GetAllAlertsFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
if m.GetAlertFunc != nil {
return m.GetAlertFunc(ctx, id)
}
return models.AlertConfig{}, nil
}
func (m *BaseMock) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error {
return nil
}
func (m *BaseMock) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error {
return nil
}
func (m *BaseMock) DeleteAlert(_ context.Context, _ int) error { return nil }
func (m *BaseMock) GetSiteByName(_ context.Context, _ string) (models.SiteConfig, error) {
return models.SiteConfig{}, nil
}
func (m *BaseMock) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *BaseMock) AddSiteReturningID(_ context.Context, _ models.SiteConfig) (int, error) {
return 0, nil
}
func (m *BaseMock) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) {
return 0, nil
}
func (m *BaseMock) GetAllUsers(ctx context.Context) ([]models.User, error) {
if m.GetAllUsersFunc != nil {
return m.GetAllUsersFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil }
func (m *BaseMock) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error {
return nil
}
func (m *BaseMock) DeleteUser(_ context.Context, _ int) error { return nil }
func (m *BaseMock) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
if m.SaveCheckFunc != nil {
return m.SaveCheckFunc(ctx, siteID, latencyNs, isUp)
}
return nil
}
func (m *BaseMock) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
if m.SaveCheckFromNodeFunc != nil {
return m.SaveCheckFromNodeFunc(ctx, siteID, nodeID, latencyNs, isUp)
}
return nil
}
func (m *BaseMock) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
if m.LoadAllHistoryFunc != nil {
return m.LoadAllHistoryFunc(ctx, limit)
}
return nil, nil
}
func (m *BaseMock) PruneCheckHistory(_ context.Context) error { return nil }
func (m *BaseMock) SaveStateChange(ctx context.Context, siteID int, from, to, reason string) error {
if m.SaveStateChangeFunc != nil {
return m.SaveStateChangeFunc(ctx, siteID, from, to, reason)
}
return nil
}
func (m *BaseMock) GetStateChanges(ctx context.Context, siteID, limit int) ([]models.StateChange, error) {
if m.GetStateChangesFunc != nil {
return m.GetStateChangesFunc(ctx, siteID, limit)
}
return nil, nil
}
func (m *BaseMock) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
if m.GetStateChangesSinceFunc != nil {
return m.GetStateChangesSinceFunc(ctx, siteID, since)
}
return nil, nil
}
func (m *BaseMock) PruneStateChanges(_ context.Context) error { return nil }
func (m *BaseMock) RegisterNode(ctx context.Context, node models.ProbeNode) error {
if m.RegisterNodeFunc != nil {
return m.RegisterNodeFunc(ctx, node)
}
return nil
}
func (m *BaseMock) GetNode(ctx context.Context, id string) (models.ProbeNode, error) {
if m.GetNodeFunc != nil {
return m.GetNodeFunc(ctx, id)
}
return models.ProbeNode{}, nil
}
func (m *BaseMock) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) {
if m.GetAllNodesFunc != nil {
return m.GetAllNodesFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil }
func (m *BaseMock) DeleteNode(_ context.Context, _ string) error { return nil }
func (m *BaseMock) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) {
if m.LoadAlertHealthFunc != nil {
return m.LoadAlertHealthFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error {
if m.SaveAlertHealthFunc != nil {
return m.SaveAlertHealthFunc(ctx, h)
}
return nil
}
func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
if m.SaveLogFunc != nil {
return m.SaveLogFunc(ctx, message)
}
return nil
}
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
if m.GetActiveMaintenanceWindowsFunc != nil {
return m.GetActiveMaintenanceWindowsFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
if m.GetAllMaintenanceWindowsFunc != nil {
return m.GetAllMaintenanceWindowsFunc(ctx, limit)
}
return nil, nil
}
func (m *BaseMock) GetOverlappingMaintenanceWindows(_ context.Context, _ int, _, _ time.Time) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
return nil
}
func (m *BaseMock) EndMaintenanceWindow(_ context.Context, _ int) error { return nil }
func (m *BaseMock) DeleteMaintenanceWindow(_ context.Context, _ int) error { return nil }
func (m *BaseMock) PruneExpiredMaintenanceWindows(_ context.Context, _ time.Duration) (int64, error) {
return 0, nil
}
func (m *BaseMock) IsMonitorInMaintenance(ctx context.Context, id int) (bool, error) {
if m.IsMonitorInMaintenanceFunc != nil {
return m.IsMonitorInMaintenanceFunc(ctx, id)
}
return false, nil
}
func (m *BaseMock) GetPreference(ctx context.Context, key string) (string, error) {
if m.GetPreferenceFunc != nil {
return m.GetPreferenceFunc(ctx, key)
}
return "", nil
}
func (m *BaseMock) SetPreference(ctx context.Context, key, value string) error {
if m.SetPreferenceFunc != nil {
return m.SetPreferenceFunc(ctx, key, value)
}
return nil
}
func (m *BaseMock) ExportData(ctx context.Context) (models.Backup, error) {
if m.ExportDataFunc != nil {
return m.ExportDataFunc(ctx)
}
return models.Backup{}, nil
}
func (m *BaseMock) ImportData(ctx context.Context, data models.Backup) error {
if m.ImportDataFunc != nil {
return m.ImportDataFunc(ctx, data)
}
return nil
}
-132
View File
@@ -1,132 +0,0 @@
package tui
import (
"fmt"
"strings"
"time"
)
func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string {
if len(latencies) == 0 || width < 20 || height < 1 {
return ""
}
var minMs, maxMs, sumMs int64
minMs = latencies[0].Milliseconds()
maxMs = minMs
upCount := 0
for i, l := range latencies {
ms := l.Milliseconds()
if i < len(statuses) && !statuses[i] {
continue
}
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
upCount++
}
var avgMs int64
if upCount > 0 {
avgMs = sumMs / int64(upCount)
}
maxLabel := fmt.Sprintf("%dms", maxMs)
minLabel := fmt.Sprintf("%dms", minMs)
labelW := len(maxLabel)
if len(minLabel) > labelW {
labelW = len(minLabel)
}
labelW += 1
chartW := width - labelW
if chartW > len(latencies) {
chartW = len(latencies)
}
if chartW < 10 {
chartW = 10
}
samples := latencies
sampledStatuses := statuses
if len(samples) > chartW {
samples = samples[len(samples)-chartW:]
if len(sampledStatuses) > chartW {
sampledStatuses = sampledStatuses[len(sampledStatuses)-chartW:]
}
}
spread := maxMs - minMs
if spread == 0 {
spread = 1
}
totalLevels := height * 8
levels := make([]int, len(samples))
for i, l := range samples {
ms := l.Milliseconds()
if i < len(sampledStatuses) && !sampledStatuses[i] {
levels[i] = 0
continue
}
lvl := int(float64(ms-minMs) / float64(spread) * float64(totalLevels-1))
if lvl < 1 && ms > 0 {
lvl = 1
}
if lvl >= totalLevels {
lvl = totalLevels - 1
}
levels[i] = lvl
}
labelStyle := m.st.subtleStyle
var rows []string
for row := height - 1; row >= 0; row-- {
var label string
switch row {
case height - 1:
label = fmt.Sprintf("%*s", labelW, maxLabel)
case 0:
label = fmt.Sprintf("%*s", labelW, minLabel)
default:
label = strings.Repeat(" ", labelW)
}
var sb strings.Builder
sb.WriteString(labelStyle.Render(label))
rowBase := row * 8
for i := range samples {
fill := levels[i] - rowBase
if fill <= 0 {
sb.WriteString(" ")
continue
}
if fill > 8 {
fill = 8
}
ch := string(sparkChars[fill-1])
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
if isDown {
sb.WriteString(m.st.dangerStyle.Render(ch))
} else {
sb.WriteString(m.latencyStyle(samples[i].Milliseconds(), nil).Render(ch))
}
}
rows = append(rows, sb.String())
}
stats := fmt.Sprintf("%*s Min %s Avg %s Max %s",
labelW, "",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
rows = append(rows, stats)
return strings.Join(rows, "\n")
}
-20
View File
@@ -1,20 +0,0 @@
package tui
import "time"
const (
nodeOnlineThreshold = 60 * time.Second
nodeStaleThreshold = 5 * time.Minute
uptimeGoodPct = 99.0
uptimeExcellentPct = 99.9
uptimeWarnPct = 95.0
uptimePrecisionPct = 99.99
stateHistoryLookback = 30 * 24 * time.Hour
stateHistoryDays = 30
stateHistoryLimit = 100
httpErrorThreshold = 400
errorDetailMaxLen = 30
)
-252
View File
@@ -1,252 +0,0 @@
package tui
import (
"context"
"encoding/json"
"sort"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
tea "github.com/charmbracelet/bubbletea"
)
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
m := make(map[int]bool)
raw, err := s.GetPreference(ctx, "collapsed_groups")
if err != nil || raw == "" {
return m
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return m
}
for _, id := range ids {
m[id] = true
}
return m
}
// collapsedJSON snapshots the collapsed-group set for persistence. Marshaling
// happens on the UI goroutine so the write Cmd never reads the live map.
func collapsedJSON(collapsed map[int]bool) string {
var ids []int
for id, v := range collapsed {
if v {
ids = append(ids, id)
}
}
data, _ := json.Marshal(ids)
return string(data)
}
// writeCmd runs a store mutation off the UI goroutine. The closure must only
// capture values snapshotted in Update — never the model itself.
func writeCmd(op string, fn func() error) tea.Cmd {
return func() tea.Msg {
return writeDoneMsg{op: op, err: fn()}
}
}
func (m *Model) saveBottomPanelPref() tea.Cmd {
v := "logs"
switch m.bottomPanel {
case bottomNone:
v = "none"
case bottomMaint:
v = "maint"
}
st := m.store
ctx := m.ctx
return writeCmd("Save bottom panel preference", func() error {
return st.SetPreference(ctx, "bottom_panel", v)
})
}
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
var groups, ungrouped []models.Site
children := make(map[int][]models.Site)
for _, s := range allSites {
if s.Type == "group" {
groups = append(groups, s)
} else if s.ParentID > 0 {
children[s.ParentID] = append(children[s.ParentID], s)
} else {
ungrouped = append(ungrouped, s)
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
sortSlice := func(s []models.Site) {
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
sort.SliceStable(s, func(i, j int) bool {
var less bool
switch sortCol {
case sortName:
less = strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
case sortLatency:
less = s[i].Latency < s[j].Latency
default:
less = siteOrder(s[i]) < siteOrder(s[j])
}
if !sortAsc {
return less
}
return !less
})
}
for pid := range children {
c := children[pid]
sortSlice(c)
children[pid] = c
}
sortSlice(ungrouped)
var ordered []models.Site
for _, g := range groups {
ordered = append(ordered, g)
if !collapsed[g.ID] {
ordered = append(ordered, children[g.ID]...)
}
}
ordered = append(ordered, ungrouped...)
return ordered
}
func filterSites(sites []models.Site, needle string) []models.Site {
lower := strings.ToLower(needle)
var filtered []models.Site
for _, s := range sites {
if strings.Contains(strings.ToLower(s.Name), lower) {
filtered = append(filtered, s)
}
}
return filtered
}
// refreshLive updates everything sourced from in-memory engine copies — the
// live site list (sorted + filtered) and the log viewport. It does no database
// IO, so it is safe to call on every tick. DB-backed tab data is loaded
// separately via loadTabDataCmd.
func (m *Model) refreshLive() {
allSites := m.engine.GetAllSites()
ordered := sortSitesForDisplay(allSites, m.collapsed, m.sortColumn, m.sortAsc)
if m.filterText != "" {
ordered = filterSites(ordered, m.filterText)
}
m.sites = ordered
m.buildMaintSet()
m.refreshLogContent()
if m.selectedID != 0 {
for i, s := range m.sites {
if s.ID == m.selectedID {
m.cursor = i
break
}
}
}
m.clampCursor()
}
func (m *Model) syncSelectedID() {
if m.cursor < len(m.sites) {
m.selectedID = m.sites[m.cursor].ID
}
}
// clampCursor keeps the cursor and scroll offset within the current tab's list.
func (m *Model) clampCursor() {
listLen := m.currentListLen()
if listLen > 0 && m.cursor >= listLen {
m.cursor = listLen - 1
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
}
// loadTabDataCmd returns a tea.Cmd that loads the DB-backed tab tables off the
// UI goroutine. Each call bumps tabSeq and stamps the reply with it, so
// handleTabData can drop out-of-order results from slower earlier loads. The
// closure reads only stable fields (store, isAdmin) and never mutates the
// model; results come back as a tabDataMsg. On the first store error it
// returns an error-only msg so the model keeps its previous data.
func (m *Model) loadTabDataCmd() tea.Cmd {
m.tabSeq++
seq := m.tabSeq
st := m.store
ctx := m.ctx
isAdmin := m.isAdmin
return func() tea.Msg {
alerts, err := st.GetAllAlerts(ctx)
if err != nil {
return tabDataMsg{seq: seq, err: err}
}
var users []models.User
if isAdmin {
if users, err = st.GetAllUsers(ctx); err != nil {
return tabDataMsg{seq: seq, err: err}
}
}
nodes, err := st.GetAllNodes(ctx)
if err != nil {
return tabDataMsg{seq: seq, err: err}
}
maint, err := st.GetAllMaintenanceWindows(ctx, 100)
if err != nil {
return tabDataMsg{seq: seq, err: err}
}
return tabDataMsg{seq: seq, alerts: alerts, users: users, nodes: nodes, maint: maint}
}
}
// loadDetailCmd loads the state-change history for the detail panel off the UI
// goroutine. View renders the cached result rather than querying the DB.
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
var currentStatus models.Status
for _, s := range m.sites {
if s.ID == siteID {
currentStatus = s.Status
break
}
}
return func() tea.Msg {
changes := eng.GetStateChanges(ctx, siteID, 5)
now := time.Now()
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
}
}
// loadHistoryCmd loads the full state-change history for the history view off
// the UI goroutine.
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
eng := m.engine
ctx := m.ctx
return func() tea.Msg {
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
}
}
// loadSLACmd loads the state changes backing the SLA view off the UI
// goroutine. The reply carries the request's site and period so a stale reply
// can be recognized and dropped.
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
eng := m.engine
ctx := m.ctx
since := time.Now().Add(-slaPeriods[periodIdx].duration)
return func() tea.Msg {
return slaDataMsg{
siteID: siteID,
periodIdx: periodIdx,
changes: eng.GetStateChangesSince(ctx, siteID, since),
}
}
}
-95
View File
@@ -1,95 +0,0 @@
package tui
import (
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 3, Name: "ungrouped", Type: "http"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
}
result := sortSitesForDisplay(sites, nil, sortStatus, false)
if len(result) != 3 {
t.Fatalf("expected 3 sites, got %d", len(result))
}
if result[0].Name != "group-a" {
t.Errorf("first should be group, got %s", result[0].Name)
}
if result[1].Name != "child" {
t.Errorf("second should be child, got %s", result[1].Name)
}
if result[2].Name != "ungrouped" {
t.Errorf("third should be ungrouped, got %s", result[2].Name)
}
}
func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child-1", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
}
collapsed := map[int]bool{1: true}
result := sortSitesForDisplay(sites, collapsed, sortStatus, false)
if len(result) != 1 {
t.Fatalf("collapsed group should hide children, got %d items", len(result))
}
if result[0].Name != "group-a" {
t.Errorf("only group should remain, got %s", result[0].Name)
}
}
func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "up-site", Type: "http"}, SiteState: models.SiteState{Status: "UP"}},
{SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}},
{SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}},
}
result := sortSitesForDisplay(sites, nil, sortStatus, false)
if result[0].Status != "DOWN" {
t.Errorf("DOWN should sort first, got %s", result[0].Status)
}
if result[1].Status != "LATE" {
t.Errorf("LATE should sort second, got %s", result[1].Status)
}
if result[2].Status != "UP" {
t.Errorf("UP should sort third, got %s", result[2].Status)
}
}
func TestFilterSites(t *testing.T) {
sites := []models.Site{
{SiteConfig: models.SiteConfig{Name: "Production API"}},
{SiteConfig: models.SiteConfig{Name: "Staging API"}},
{SiteConfig: models.SiteConfig{Name: "Database"}},
}
tests := []struct {
needle string
want int
}{
{"api", 2},
{"API", 2},
{"database", 1},
{"nonexistent", 0},
{"", 3},
}
for _, tt := range tests {
got := filterSites(sites, tt.needle)
if len(got) != tt.want {
t.Errorf("filterSites(%q) returned %d, want %d", tt.needle, len(got), tt.want)
}
}
}
func TestFilterSites_EmptyNeedle(t *testing.T) {
sites := []models.Site{{SiteConfig: models.SiteConfig{Name: "a"}}, {SiteConfig: models.SiteConfig{Name: "b"}}}
got := filterSites(sites, "")
if len(got) != 2 {
t.Errorf("empty needle should return all, got %d", len(got))
}
}
-199
View File
@@ -1,199 +0,0 @@
package tui
import (
"fmt"
"strings"
)
type ErrorCategory string
const (
ErrCatDNS ErrorCategory = "DNS"
ErrCatTCP ErrorCategory = "TCP"
ErrCatTLS ErrorCategory = "TLS"
ErrCatHTTP ErrorCategory = "HTTP"
ErrCatICMP ErrorCategory = "ICMP"
ErrCatTimeout ErrorCategory = "TMO"
ErrCatPrivate ErrorCategory = "PRIV"
ErrCatUnknown ErrorCategory = ""
)
func classifyError(errorReason string, siteType string, statusCode int) ErrorCategory {
if errorReason == "" {
return ErrCatUnknown
}
lower := strings.ToLower(errorReason)
if strings.Contains(lower, "resolves to private") || strings.Contains(lower, "private address") || strings.Contains(lower, "private ip") {
return ErrCatPrivate
}
if strings.Contains(lower, "tls handshake") || strings.Contains(lower, "x509") ||
strings.Contains(lower, "certificate") || strings.Contains(lower, "ssl") {
return ErrCatTLS
}
if strings.Contains(lower, "no such host") || strings.Contains(lower, "nxdomain") ||
strings.Contains(lower, "dns query") || strings.Contains(lower, "dns rcode") ||
strings.Contains(lower, "servfail") || strings.Contains(lower, "server misbehaving") {
return ErrCatDNS
}
if strings.Contains(lower, "i/o timeout") || strings.Contains(lower, "deadline exceeded") ||
strings.Contains(lower, "context canceled") {
return ErrCatTimeout
}
if strings.Contains(lower, "no icmp response") || strings.Contains(lower, "ping setup") ||
strings.Contains(lower, "ping failed") {
return ErrCatICMP
}
if strings.Contains(lower, "connection refused") || strings.Contains(lower, "connection reset") ||
strings.Contains(lower, "no route to host") || strings.Contains(lower, "network unreachable") ||
strings.Contains(lower, "network is unreachable") || strings.Contains(lower, "failed to connect") {
return ErrCatTCP
}
if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") {
return ErrCatHTTP
}
if statusCode >= httpErrorThreshold {
return ErrCatHTTP
}
return ErrCatUnknown
}
func categoryTag(cat ErrorCategory) string {
if cat == ErrCatUnknown {
return ""
}
return "[" + string(cat) + "]"
}
type stepStatus int
const (
stepPassed stepStatus = iota
stepFailed
stepSkipped
)
type chainStep struct {
Name string
Status stepStatus
Detail string
}
func connectionChain(errorReason string, siteType string, statusCode int, isHTTPS bool) []chainStep {
if siteType != "http" {
return nil
}
if errorReason == "" {
return nil
}
cat := classifyError(errorReason, siteType, statusCode)
detail := extractDetail(errorReason, cat, statusCode)
steps := []struct {
name string
cat ErrorCategory
}{
{"DNS resolve", ErrCatDNS},
{"TCP connect", ErrCatTCP},
}
if isHTTPS {
steps = append(steps, struct {
name string
cat ErrorCategory
}{"TLS handshake", ErrCatTLS})
}
steps = append(steps, struct {
name string
cat ErrorCategory
}{"HTTP response", ErrCatHTTP})
chain := make([]chainStep, 0, len(steps))
failed := false
for _, s := range steps {
switch {
case failed:
chain = append(chain, chainStep{Name: s.name, Status: stepSkipped, Detail: "(skipped)"})
case s.cat == cat || (cat == ErrCatTimeout && s.cat == ErrCatTCP) || (cat == ErrCatPrivate && s.cat == ErrCatDNS):
chain = append(chain, chainStep{Name: s.name, Status: stepFailed, Detail: detail})
failed = true
default:
chain = append(chain, chainStep{Name: s.name, Status: stepPassed})
}
}
return chain
}
func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string {
lower := strings.ToLower(errorReason)
var detail string
switch cat {
case ErrCatDNS:
for _, keyword := range []string{"NXDOMAIN", "SERVFAIL", "REFUSED", "no such host"} {
if strings.Contains(strings.ToUpper(errorReason), strings.ToUpper(keyword)) {
detail = keyword
break
}
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTCP:
for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} {
if strings.Contains(lower, keyword) {
detail = keyword
break
}
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTLS:
for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} {
if strings.Contains(lower, keyword) {
detail = keyword
break
}
}
if detail == "" && strings.Contains(lower, "x509:") {
idx := strings.Index(lower, "x509:")
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
}
if detail == "" {
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatHTTP:
if statusCode > 0 {
detail = fmt.Sprintf("HTTP %d", statusCode)
} else {
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatTimeout:
if strings.Contains(lower, "i/o timeout") {
detail = "i/o timeout"
} else {
detail = "deadline exceeded"
}
case ErrCatICMP:
if strings.Contains(lower, "no icmp response") {
detail = "no response"
} else {
detail = limitStr(errorReason, errorDetailMaxLen)
}
case ErrCatPrivate:
detail = "private IP blocked"
default:
detail = limitStr(errorReason, errorDetailMaxLen)
}
return detail
}
-187
View File
@@ -1,187 +0,0 @@
package tui
import "testing"
func TestClassifyError(t *testing.T) {
tests := []struct {
reason string
siteType string
statusCode int
want ErrorCategory
}{
{"", "http", 0, ErrCatUnknown},
{"some unknown error", "http", 0, ErrCatUnknown},
// PRIV
{"target resolves to private IP", "http", 0, ErrCatPrivate},
{"blocked: host resolves to private address 10.0.0.1", "port", 0, ErrCatPrivate},
// TLS
{"SSL certificate expired", "http", 0, ErrCatTLS},
{"tls handshake failure", "http", 0, ErrCatTLS},
{"x509: certificate has expired", "http", 0, ErrCatTLS},
{"x509: certificate signed by unknown authority", "http", 0, ErrCatTLS},
// DNS
{"DNS query failed: NXDOMAIN", "http", 0, ErrCatDNS},
{"DNS RCODE: SERVFAIL", "dns", 0, ErrCatDNS},
{"dial tcp: lookup example.com: no such host", "http", 0, ErrCatDNS},
{"DNS query failed: server misbehaving", "dns", 0, ErrCatDNS},
// TMO
{"dial tcp 1.2.3.4:443: i/o timeout", "http", 0, ErrCatTimeout},
{"context deadline exceeded", "http", 0, ErrCatTimeout},
{"Get https://example.com: context canceled", "http", 0, ErrCatTimeout},
// ICMP
{"no ICMP response", "ping", 0, ErrCatICMP},
{"ping setup: permission denied", "ping", 0, ErrCatICMP},
{"ping failed: packet loss", "ping", 0, ErrCatICMP},
// TCP
{"dial tcp 1.2.3.4:80: connect: connection refused", "port", 0, ErrCatTCP},
{"connection reset by peer", "http", 0, ErrCatTCP},
{"dial tcp: no route to host", "http", 0, ErrCatTCP},
{"network unreachable", "http", 0, ErrCatTCP},
{"failed to connect to 10.0.0.1:443", "http", 0, ErrCatTCP},
// HTTP
{"HTTP 500 (expected 200-299)", "http", 500, ErrCatHTTP},
{"HTTP 403 (expected 200-299)", "http", 403, ErrCatHTTP},
{"keyword not found", "http", 200, ErrCatHTTP},
{"", "http", 502, ErrCatUnknown},
{"unexpected status", "http", 404, ErrCatHTTP},
}
for _, tt := range tests {
got := classifyError(tt.reason, tt.siteType, tt.statusCode)
if got != tt.want {
t.Errorf("classifyError(%q, %q, %d) = %q, want %q",
tt.reason, tt.siteType, tt.statusCode, got, tt.want)
}
}
}
func TestCategoryTag(t *testing.T) {
tests := []struct {
cat ErrorCategory
want string
}{
{ErrCatDNS, "[DNS]"},
{ErrCatTCP, "[TCP]"},
{ErrCatTLS, "[TLS]"},
{ErrCatHTTP, "[HTTP]"},
{ErrCatICMP, "[ICMP]"},
{ErrCatTimeout, "[TMO]"},
{ErrCatPrivate, "[PRIV]"},
{ErrCatUnknown, ""},
}
for _, tt := range tests {
got := categoryTag(tt.cat)
if got != tt.want {
t.Errorf("categoryTag(%q) = %q, want %q", tt.cat, got, tt.want)
}
}
}
func TestConnectionChain(t *testing.T) {
t.Run("nil for non-http", func(t *testing.T) {
if chain := connectionChain("no ICMP response", "ping", 0, false); chain != nil {
t.Errorf("expected nil for ping, got %v", chain)
}
})
t.Run("nil for empty error", func(t *testing.T) {
if chain := connectionChain("", "http", 0, true); chain != nil {
t.Errorf("expected nil for empty error, got %v", chain)
}
})
t.Run("DNS failure HTTPS", func(t *testing.T) {
chain := connectionChain("no such host", "http", 0, true)
if len(chain) != 4 {
t.Fatalf("expected 4 steps, got %d", len(chain))
}
if chain[0].Status != stepFailed {
t.Errorf("DNS step: want failed, got %d", chain[0].Status)
}
if chain[1].Status != stepSkipped {
t.Errorf("TCP step: want skipped, got %d", chain[1].Status)
}
if chain[2].Status != stepSkipped {
t.Errorf("TLS step: want skipped, got %d", chain[2].Status)
}
if chain[3].Status != stepSkipped {
t.Errorf("HTTP step: want skipped, got %d", chain[3].Status)
}
})
t.Run("TCP failure HTTP", func(t *testing.T) {
chain := connectionChain("connection refused", "http", 0, false)
if len(chain) != 3 {
t.Fatalf("expected 3 steps (no TLS), got %d", len(chain))
}
if chain[0].Status != stepPassed {
t.Errorf("DNS step: want passed, got %d", chain[0].Status)
}
if chain[1].Status != stepFailed {
t.Errorf("TCP step: want failed, got %d", chain[1].Status)
}
if chain[2].Status != stepSkipped {
t.Errorf("HTTP step: want skipped, got %d", chain[2].Status)
}
})
t.Run("TLS failure HTTPS", func(t *testing.T) {
chain := connectionChain("x509: certificate has expired", "http", 0, true)
if len(chain) != 4 {
t.Fatalf("expected 4 steps, got %d", len(chain))
}
if chain[0].Status != stepPassed {
t.Errorf("DNS: want passed, got %d", chain[0].Status)
}
if chain[1].Status != stepPassed {
t.Errorf("TCP: want passed, got %d", chain[1].Status)
}
if chain[2].Status != stepFailed {
t.Errorf("TLS: want failed, got %d", chain[2].Status)
}
if chain[3].Status != stepSkipped {
t.Errorf("HTTP: want skipped, got %d", chain[3].Status)
}
})
t.Run("HTTP failure HTTPS", func(t *testing.T) {
chain := connectionChain("HTTP 500 (expected 200-299)", "http", 500, true)
if len(chain) != 4 {
t.Fatalf("expected 4 steps, got %d", len(chain))
}
for i := 0; i < 3; i++ {
if chain[i].Status != stepPassed {
t.Errorf("step %d: want passed, got %d", i, chain[i].Status)
}
}
if chain[3].Status != stepFailed {
t.Errorf("HTTP: want failed, got %d", chain[3].Status)
}
if chain[3].Detail != "HTTP 500" {
t.Errorf("HTTP detail: want %q, got %q", "HTTP 500", chain[3].Detail)
}
})
t.Run("timeout maps to TCP step", func(t *testing.T) {
chain := connectionChain("i/o timeout", "http", 0, true)
if chain[1].Status != stepFailed {
t.Errorf("TCP step: want failed for timeout, got %d", chain[1].Status)
}
if chain[1].Detail != "i/o timeout" {
t.Errorf("detail: want %q, got %q", "i/o timeout", chain[1].Detail)
}
})
t.Run("private IP maps to DNS step", func(t *testing.T) {
chain := connectionChain("target resolves to private IP", "http", 0, true)
if chain[0].Status != stepFailed {
t.Errorf("DNS step: want failed for private IP, got %d", chain[0].Status)
}
})
}
-247
View File
@@ -1,247 +0,0 @@
package tui
import (
"fmt"
"strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss"
)
func (m Model) dividerWidth() int {
w := m.termWidth - chromePadH - 4
if w < 40 {
w = 40
}
return w
}
func (m Model) divider() string {
return " " + m.st.subtleStyle.Render(strings.Repeat("─", m.dividerWidth()))
}
func (m Model) emptyState(message, hint string) string {
content := message
if hint != "" {
content += "\n\n" + m.st.subtleStyle.Render(hint)
}
return "\n" + lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Accent).
Padding(1, 3).
Render(content)
}
func limitStr(text string, max int) string {
if max < 3 {
return text
}
runes := []rune(text)
if len(runes) > max {
return string(runes[:max-3]) + "..."
}
return text
}
func siteOrder(s models.Site) int {
if s.Paused {
return 3
}
switch s.Status {
case models.StatusDown, models.StatusSSLExp:
return 0
case models.StatusStale:
return 1
case models.StatusLate:
return 1
case models.StatusPending:
return 3
default:
return 2
}
}
func typeIcon(siteType string, collapsed bool) string {
switch siteType {
case "http":
return "→"
case "push":
return "↓"
case "ping":
return "↔"
case "port":
return "⊡"
case "dns":
return "◆"
case "group":
if collapsed {
return "▶"
}
return "▼"
default:
return "·"
}
}
func (m Model) fmtLatency(d time.Duration) string {
ms := d.Milliseconds()
if ms == 0 {
return m.st.subtleStyle.Render("—")
}
var s string
if ms < 1000 {
s = fmt.Sprintf("%dms", ms)
} else {
s = fmt.Sprintf("%.1fs", float64(ms)/1000)
}
if ms < 200 {
return m.st.specialStyle.Render(s)
}
if ms < 500 {
return m.st.warnStyle.Render(s)
}
return m.st.dangerStyle.Render(s)
}
func (m Model) fmtUptimeMaint(statuses []bool, siteID int) string {
if m.isMonitorInMaintenance(siteID) {
return m.st.subtleStyle.Render("—")
}
return m.fmtUptime(statuses)
}
func (m Model) fmtUptime(statuses []bool) string {
if len(statuses) == 0 {
return m.st.subtleStyle.Render("—")
}
up := 0
for _, s := range statuses {
if s {
up++
}
}
pct := float64(up) / float64(len(statuses)) * 100
s := fmt.Sprintf("%.1f%%", pct)
if pct >= 99 {
return m.st.specialStyle.Render(s)
}
if pct >= 95 {
return m.st.warnStyle.Render(s)
}
return m.st.dangerStyle.Render(s)
}
func (m Model) fmtSSL(site models.Site) string {
if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
return m.st.subtleStyle.Render("-")
}
days := int(time.Until(site.CertExpiry).Hours() / 24)
s := fmt.Sprintf("%dd", days)
if days <= 0 {
return m.st.dangerStyle.Render("EXPIRED")
}
if days <= site.ExpiryThreshold {
return m.st.warnStyle.Render(s)
}
return m.st.specialStyle.Render(s)
}
func (m Model) fmtRetries(site models.Site) string {
dispCount := site.FailureCount
if dispCount > site.MaxRetries {
dispCount = site.MaxRetries
}
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
if site.Status == models.StatusDown {
return m.st.dangerStyle.Render(s)
}
if site.Status == models.StatusUp && site.FailureCount > 0 {
return m.st.warnStyle.Render(s)
}
return s
}
func (m Model) fmtStatusDot(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇")
}
if inMaint {
return m.st.maintStyle.Render("◼")
}
switch status {
case models.StatusDown, models.StatusSSLExp:
return m.st.dangerStyle.Render("▼")
case models.StatusLate:
return m.st.warnStyle.Render("◆")
case models.StatusStale:
return m.st.staleStyle.Render("◆")
case models.StatusPending:
return m.st.subtleStyle.Render("○")
default:
return m.st.specialStyle.Render("▲")
}
}
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string {
if paused {
return m.st.warnStyle.Render("◇ PAUSED")
}
if inMaint {
return m.st.maintStyle.Render("◼ MAINT")
}
switch status {
case models.StatusDown:
return m.st.dangerStyle.Render("▼ DOWN")
case models.StatusSSLExp:
return m.st.dangerStyle.Render("▼ SSL EXP")
case models.StatusLate:
return m.st.warnStyle.Render("◆ LATE")
case models.StatusStale:
return m.st.staleStyle.Render("◆ STALE")
case models.StatusPending:
return m.st.subtleStyle.Render("○ PENDING")
default:
return m.st.specialStyle.Render("▲ " + string(status))
}
}
func (m Model) fmtTimeAgo(t time.Time) string {
if t.IsZero() {
return m.st.subtleStyle.Render("never")
}
d := time.Since(t)
if d < time.Minute {
return fmt.Sprintf("%ds ago", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm ago", int(d.Minutes()))
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
}
func fmtDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if m > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dh", h)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
if hours > 0 {
return fmt.Sprintf("%dd %dh", days, hours)
}
return fmt.Sprintf("%dd", days)
}
-175
View File
@@ -1,175 +0,0 @@
package tui
import (
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// styledModel carries a default-theme styles instance for render-helper tests.
var styledModel = Model{st: newStyles(themeFlexokiDark)}
func TestLimitStr(t *testing.T) {
tests := []struct {
input string
max int
want string
}{
{"hello", 10, "hello"},
{"hello", 5, "hello"},
{"hello world", 8, "hello..."},
{"", 5, ""},
{"abc", 3, "abc"},
{"abcd", 3, "..."},
{"日本語テスト", 4, "日..."},
}
for _, tt := range tests {
got := limitStr(tt.input, tt.max)
if got != tt.want {
t.Errorf("limitStr(%q, %d) = %q, want %q", tt.input, tt.max, got, tt.want)
}
}
}
func TestSiteOrder(t *testing.T) {
tests := []struct {
name string
site models.Site
want int
}{
{"down", models.Site{SiteState: models.SiteState{Status: "DOWN"}}, 0},
{"ssl exp", models.Site{SiteState: models.SiteState{Status: "SSL EXP"}}, 0},
{"late", models.Site{SiteState: models.SiteState{Status: "LATE"}}, 1},
{"up", models.Site{SiteState: models.SiteState{Status: "UP"}}, 2},
{"pending", models.Site{SiteState: models.SiteState{Status: "PENDING"}}, 3},
{"paused up", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "UP"}}, 3},
{"paused down", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "DOWN"}}, 3},
}
for _, tt := range tests {
got := siteOrder(tt.site)
if got != tt.want {
t.Errorf("siteOrder(%s) = %d, want %d", tt.name, got, tt.want)
}
}
}
func TestFmtStatus(t *testing.T) {
tests := []struct {
status models.Status
paused bool
inMaint bool
wantSub string
}{
{models.StatusDown, false, false, "▼ DOWN"},
{models.StatusUp, false, false, "▲ UP"},
{models.StatusSSLExp, false, false, "▼ SSL EXP"},
{models.StatusLate, false, false, "◆ LATE"},
{models.StatusStale, false, false, "◆ STALE"},
{models.StatusPending, false, false, "○ PENDING"},
{models.StatusDown, true, false, "◇ PAUSED"},
{models.StatusDown, false, true, "◼ MAINT"},
}
for _, tt := range tests {
got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint)
if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtStatus(%q, paused=%v, maint=%v): %q missing %q",
tt.status, tt.paused, tt.inMaint, got, tt.wantSub)
}
}
}
func TestFmtDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{30 * time.Second, "30s"},
{5 * time.Minute, "5m"},
{2*time.Hour + 30*time.Minute, "2h 30m"},
{2 * time.Hour, "2h"},
{25 * time.Hour, "1d 1h"},
{48 * time.Hour, "2d"},
{49 * time.Hour, "2d 1h"},
}
for _, tt := range tests {
got := fmtDuration(tt.d)
if got != tt.want {
t.Errorf("fmtDuration(%v) = %q, want %q", tt.d, got, tt.want)
}
}
}
func TestTypeIcon(t *testing.T) {
tests := []struct {
siteType string
collapsed bool
want string
}{
{"http", false, "→"},
{"push", false, "↓"},
{"ping", false, "↔"},
{"port", false, "⊡"},
{"dns", false, "◆"},
{"group", false, "▼"},
{"group", true, "▶"},
{"unknown", false, "·"},
}
for _, tt := range tests {
got := typeIcon(tt.siteType, tt.collapsed)
if got != tt.want {
t.Errorf("typeIcon(%q, %v) = %q, want %q", tt.siteType, tt.collapsed, got, tt.want)
}
}
}
func TestFmtUptime(t *testing.T) {
tests := []struct {
name string
statuses []bool
wantSub string
}{
{"empty", nil, "—"},
{"all up", []bool{true, true, true, true}, "100.0%"},
{"half", []bool{true, false, true, false}, "50.0%"},
{"all down", []bool{false, false}, "0.0%"},
}
for _, tt := range tests {
got := styledModel.fmtUptime(tt.statuses)
if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub)
}
}
}
func TestFmtLatency(t *testing.T) {
tests := []struct {
d time.Duration
wantSub string
}{
{0, "—"},
{50 * time.Millisecond, "50ms"},
{300 * time.Millisecond, "300ms"},
{1500 * time.Millisecond, "1.5s"},
}
for _, tt := range tests {
got := styledModel.fmtLatency(tt.d)
if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub)
}
}
}
func containsPlain(styled, sub string) bool {
// ANSI-styled strings contain the substring somewhere
return len(styled) > 0 && contains(styled, sub)
}
func contains(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return len(sub) == 0
}

Some files were not shown because too many files have changed in this diff Show More