Compare commits
232 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1cfa0571c8
|
|||
|
d6ba7d9af8
|
|||
|
14cec4283d
|
|||
|
a32a443a4a
|
|||
|
b90033c7f0
|
|||
|
989dd1fb39
|
|||
|
faf7d36c64
|
|||
|
16f0c2eb66
|
|||
|
f0d97f5562
|
|||
|
0484153103
|
|||
|
e3d681311f
|
|||
|
f7303c946c
|
|||
|
6e936ecce3
|
|||
|
04cf12f52b
|
|||
|
33df597dda
|
|||
|
1d14f640f4
|
|||
|
631f07c242
|
|||
|
0badc2ddf5
|
|||
|
be14436701
|
|||
|
efa8894b18
|
|||
|
ef54b36e0d
|
|||
|
835844314e
|
|||
|
dc79e2baaa
|
|||
|
3e02833df4
|
|||
|
8581662237
|
|||
|
2779f9f532
|
|||
|
4321e094a3
|
|||
|
1856820c3e
|
|||
|
aeee2cd0ce
|
|||
|
7ce5af5bf6
|
|||
|
18e2d60210
|
|||
|
ab1194f74d
|
|||
|
25f4b20b87
|
|||
|
7171dcd6f8
|
|||
|
822eda4dfd
|
|||
|
a5fd3aec90
|
|||
|
8f16a09da1
|
|||
|
50f77da131
|
|||
|
edbc2beddd
|
|||
|
3a089e7c1d
|
|||
|
5cc1a005ea
|
|||
|
11344b4ae6
|
|||
|
11b647b952
|
|||
|
1268d99f5c
|
|||
|
fb50184f37
|
|||
|
99121d07d8
|
|||
|
a59edf8410
|
|||
|
6799163cd4
|
|||
|
28fb6c8889
|
|||
|
d982359f25
|
|||
|
4af800a359
|
|||
|
a8c43bdb8e
|
|||
|
d0805f61c6
|
|||
|
ad469c86eb
|
|||
|
01dd53241a
|
|||
|
81f8c71b6f
|
|||
|
7109b6fa1c
|
|||
|
e5ac4a1fec
|
|||
|
065d5d74bb
|
|||
|
08f14f3af8
|
|||
|
5720fabdbc
|
|||
|
54299583d6
|
|||
|
c9bd9a5a2e
|
|||
|
66b0681a76
|
|||
|
060cd24de2
|
|||
|
5c40629987
|
|||
|
e12f42fe16
|
|||
|
8323d27e7d
|
|||
|
047bb237e0
|
|||
|
5398cccd44
|
|||
|
4bf64c3841
|
|||
|
d760420f7c
|
|||
|
0e5f2dded5
|
|||
|
07f3cc8e09
|
|||
|
ef8e5c0b93
|
|||
|
94b27488bd
|
|||
|
7d0b4dab8b
|
|||
|
d0d716b07a
|
|||
|
9889ba4417
|
|||
|
c71d5b17f0
|
|||
|
5ca534b0b1
|
|||
|
70c12ca24b
|
|||
|
dbd519c121
|
|||
|
b32145fb58
|
|||
|
47d3b0e68f
|
|||
|
8fd13fefbf
|
|||
|
974c4b61ea
|
|||
|
d50a5159d4
|
|||
|
adf8fed44f
|
|||
|
c2bfa5ad82
|
|||
|
2e07e16b45
|
|||
|
dd34da4d67
|
|||
|
de51dde6e6
|
|||
|
e2024bcab1
|
|||
|
fb4e14ecd1
|
|||
|
9ee5908af5
|
|||
|
eff67332aa
|
|||
|
dc4c5fdf8a
|
|||
|
96eb3e8185
|
|||
|
37bf443e29
|
|||
|
f53dfa1c4c
|
|||
|
4070691407
|
|||
|
6dfd56dcd4
|
|||
|
17b5557e23
|
|||
|
dc27547ffb
|
|||
|
83ec6bee42
|
|||
|
d538aad18e
|
|||
|
ab0a69d06b
|
|||
|
7bf278e538
|
|||
|
023234f4c3
|
|||
|
4328d25f22
|
|||
|
f745dcb21f
|
|||
|
e99e959b64
|
|||
|
c3eac80e14
|
|||
|
6cf0efed9b
|
|||
|
9115ab720c
|
|||
|
edfe6122b1
|
|||
|
13637ec216
|
|||
|
916c963663
|
|||
|
fa56f47f96
|
|||
|
f7da69f25f
|
|||
|
5d2b7a3e66
|
|||
|
341d60d2fe
|
|||
|
52ccd7ad91
|
|||
|
ba4465daa2
|
|||
|
54790db5c8
|
|||
|
2b357341c8
|
|||
|
0974ab2b4c
|
|||
|
f00acbc280
|
|||
|
c3ae0bd80a
|
|||
|
70a83a1da9
|
|||
|
5d5153351e
|
|||
|
61c28fac62
|
|||
|
d1ab842283
|
|||
|
92efb8e270
|
|||
|
0b64d13bb6
|
|||
|
a1ab276bc5
|
|||
|
a3711c652c
|
|||
|
634c3ee03c
|
|||
|
274f0081e2
|
|||
|
f349d0dfd1
|
|||
|
809620340e
|
|||
|
8b39d4c1a1
|
|||
|
5e7faf9ea7
|
|||
|
f97ea3d66b
|
|||
|
21a1563e53
|
|||
|
33dc84449b
|
|||
|
69a8e0f7ba
|
|||
|
cc139bdb73
|
|||
|
d53a4e6ac3
|
|||
|
65a83368bf
|
|||
|
965a864343
|
|||
| 96eb660b29 | |||
|
c471a72ff5
|
|||
|
7bff79b09c
|
|||
|
986681ef8a
|
|||
|
00fa381a7c
|
|||
|
a38a397d3a
|
|||
|
fb709b34c5
|
|||
|
33a3ff9bcb
|
|||
|
d099740f33
|
|||
|
cdb8c356e9
|
|||
|
d4a2e9dd53
|
|||
|
aae6e6e65e
|
|||
|
e0f189efe9
|
|||
| ba75be194d | |||
|
e0cb0adebd
|
|||
|
60592ef810
|
|||
| b2e92e8a2a | |||
| 66b1c662c9 | |||
| 10c6ec348e | |||
| ca43621c44 | |||
|
f23014ab12
|
|||
|
50ee878097
|
|||
|
9e15b369d3
|
|||
|
5b39be8eb2
|
|||
|
eb9546c97e
|
|||
|
1d1f5d0ee4
|
|||
|
bc661f5207
|
|||
|
c0ad51af9c
|
|||
|
c25614c098
|
|||
|
3d7ab5a49e
|
|||
|
5d362fdbe6
|
|||
|
89f3ff27e3
|
|||
| cc9f04aa2f | |||
|
771721abb4
|
|||
|
d316c5cf1b
|
|||
|
99c0ad8072
|
|||
|
048b245d25
|
|||
|
21cbaa9ff9
|
|||
|
056cf9c3f7
|
|||
| 737ada2f5e | |||
| f9e7a4d473 | |||
| a5b499c247 | |||
| c963acb574 | |||
| c293301051 | |||
| 094de23bd0 | |||
| fdadafc128 | |||
| 78fff3fb33 | |||
| aaee3f7ebf | |||
| 9b5cc37ad4 | |||
| 3a169b2bcd | |||
| 50eb43971c | |||
| a08d6ce2c5 | |||
| 24d4fb5e55 | |||
| 8d34524aa0 | |||
| b254f6ea05 | |||
| 87270490de | |||
| f80e519349 | |||
| 9a4a53f487 | |||
| 32982228b0 | |||
| ec898ff943 | |||
| 38c7739995 | |||
| 5679dffffa | |||
| 9a4985e355 | |||
| 65406ce69c | |||
| 2474b341ad | |||
| b0762800ac | |||
| 08bcdd6481 | |||
| ebf8bfb097 | |||
| b62a721277 | |||
| 8f17deba67 | |||
| 026e969b74 | |||
| cfbf01274d | |||
| 26e297cbae | |||
| 0aa2f9cd8a | |||
| f17f06a1c6 | |||
| b14d5e19db | |||
| a2b38ddc60 | |||
| 5dc31108f8 | |||
| 63773b13d0 | |||
| bc3a44beac |
@@ -1,15 +1,14 @@
|
||||
.git
|
||||
.ssh/
|
||||
.gitea/
|
||||
tmp/
|
||||
vendor/
|
||||
|
||||
# Security: keep sensitive/local files out of Docker build context
|
||||
.ssh/
|
||||
.claude/
|
||||
.github/
|
||||
.gitea/
|
||||
CLAUDE.md
|
||||
*.db
|
||||
*.db-journal
|
||||
*.local.json
|
||||
*.local.md
|
||||
*.local
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.github/
|
||||
dist/
|
||||
uptop
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ─── 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
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
@@ -67,7 +65,7 @@ jobs:
|
||||
go-version: "1.26"
|
||||
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
|
||||
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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.
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
@@ -1,68 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working as expected
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: search
|
||||
attributes:
|
||||
label: Before filing
|
||||
options:
|
||||
- label: I searched existing issues and didn't find a match
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Include what you expected to happen instead.
|
||||
placeholder: |
|
||||
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||
I expected it to keep running and display monitor status.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Run `uptop`
|
||||
2. Wait ~10 seconds
|
||||
3. TUI crashes with panic
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment & logs
|
||||
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||
render: shell
|
||||
placeholder: |
|
||||
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||
OS: Debian 13
|
||||
Terminal: Ghostty
|
||||
|
||||
[paste any error output here]
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
labels:
|
||||
- feature
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What's frustrating or missing?
|
||||
placeholder: I find myself always needing to ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How would you like this to work?
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Forward Issues to Gitea
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build issue body
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
run: |
|
||||
jq -n \
|
||||
--arg url "$ISSUE_URL" \
|
||||
--arg author "$ISSUE_AUTHOR" \
|
||||
--arg body "$ISSUE_BODY" \
|
||||
'">" + " Forwarded from GitHub: " + $url + "\n> Reported by: [@" + $author + "](https://github.com/" + $author + ")\n\n---\n\n" + $body' \
|
||||
-r > /tmp/gitea-issue-body.md || exit 1
|
||||
|
||||
- name: Resolve label IDs
|
||||
id: labels
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GH_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
|
||||
run: |
|
||||
GITEA_LABELS=$(curl -f \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/labels?limit=50") || exit 1
|
||||
|
||||
GH_LABELS_COMPACT=$(echo "$GH_LABELS" | jq -c '.')
|
||||
LABEL_IDS=$(echo "$GITEA_LABELS" | jq -c --argjson gh "$GH_LABELS_COMPACT" '
|
||||
[.[] | select(
|
||||
.name == "github" or
|
||||
(.name as $n | $gh | index($n) != null)
|
||||
) | .id]
|
||||
')
|
||||
|
||||
echo "ids=${LABEL_IDS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea issue
|
||||
id: create
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_IDS: ${{ steps.labels.outputs.ids }}
|
||||
run: |
|
||||
RESPONSE=$(curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/issues" \
|
||||
-d "$(jq -n \
|
||||
--arg title "$ISSUE_TITLE" \
|
||||
--rawfile body /tmp/gitea-issue-body.md \
|
||||
--argjson labels "$LABEL_IDS" \
|
||||
'{title: $title, body: $body, labels: $labels}'
|
||||
)") || exit 1
|
||||
|
||||
GITEA_URL=$(echo "$RESPONSE" | jq -re '.html_url') || exit 1
|
||||
GITEA_NUM=$(echo "$RESPONSE" | jq -re '.number') || exit 1
|
||||
|
||||
echo "url=${GITEA_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "number=${GITEA_NUM}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on GitHub issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_URL: ${{ steps.create.outputs.url }}
|
||||
run: |
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--body "Thanks for reporting! This issue has been forwarded to our [primary tracker](${GITEA_URL}). Discussion and updates will happen there."
|
||||
@@ -0,0 +1,81 @@
|
||||
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/*
|
||||
@@ -1,10 +1,3 @@
|
||||
# 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
|
||||
*.exe
|
||||
*.exe~
|
||||
@@ -24,20 +17,14 @@
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||
|
||||
/uptop
|
||||
uptop.db
|
||||
|
||||
/dist
|
||||
uptop.db*
|
||||
.ssh
|
||||
|
||||
authorized_keys
|
||||
|
||||
tmp
|
||||
|
||||
*.local.json
|
||||
*.local.md
|
||||
|
||||
# VHS — seed has personal URLs, tape is local workflow
|
||||
vhs/seed.yaml
|
||||
vhs/demo.tape
|
||||
data/
|
||||
.env
|
||||
vhs
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
version: 2
|
||||
|
||||
gitea_urls:
|
||||
api: https://gitea.lerkolabs.com/api/v1
|
||||
api: "{{ if index .Env \"GITEA_API_URL\" }}{{ .Env.GITEA_API_URL }}{{ else }}https://gitea.lerkolabs.com/api/v1{{ end }}"
|
||||
download: https://gitea.lerkolabs.com
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: lerko
|
||||
owner: lerkolabs
|
||||
name: uptop
|
||||
prerelease: auto
|
||||
|
||||
builds:
|
||||
- main: ./cmd/uptop/main.go
|
||||
- main: ./cmd/uptop
|
||||
binary: uptop
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{ .Version }}
|
||||
@@ -29,14 +33,51 @@ builds:
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^chore:"
|
||||
- "^style:"
|
||||
nfpms:
|
||||
- package_name: uptop
|
||||
file_name_template: "{{ .ConventionalFileName }}"
|
||||
vendor: LerkoLabs
|
||||
homepage: https://gitea.lerkolabs.com/lerkolabs/uptop
|
||||
maintainer: Tyler Koenig <tyler@lerkolabs.com>
|
||||
description: Self-hosted uptime monitoring with a TUI over SSH
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -1,46 +1,183 @@
|
||||
# Changelog
|
||||
|
||||
## [2026.05.2] — 2026-05-23
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Comprehensive test suite (94 tests across monitor, server, cluster)
|
||||
- golangci-lint config with CI enforcement
|
||||
- Gitea Actions CI pipeline (test + lint)
|
||||
- Graceful shutdown for HTTP and SSH servers
|
||||
- Context-aware alert delivery with timeout
|
||||
- Request size limits on all POST endpoints
|
||||
- Constant-time secret comparison
|
||||
- Check interval jitter to prevent thundering herd
|
||||
- `--version` flag with build metadata injection
|
||||
|
||||
- initial commit — uptime monitor (forked from go-upkeep)
|
||||
- enhanced dashboard with lipgloss tables, huh forms, mouse support, and animations
|
||||
- upgrade users tab with lipgloss table, edit support, role select
|
||||
- upgrade alerts tab with lipgloss table, click zones, colored types
|
||||
- widen Site struct and DB schema for ping, port, dns, group monitor types
|
||||
- add ping, port, and DNS check routines
|
||||
- add ntfy notification provider with TUI support
|
||||
- add Uptime Kuma backup converter with CLI and API
|
||||
- add mouse wheel scrolling for all tabs
|
||||
- add per-site pause, fix viewport, polish status page
|
||||
- add monitor groups with collapse/expand and tree view
|
||||
- add Telegram, PagerDuty, Pushover, Gotify providers
|
||||
- add Prometheus /metrics endpoint
|
||||
- expose HTTP method and accepted status codes in monitor form
|
||||
- add config-as-code YAML import/export
|
||||
- add distributed probing foundation — schema, models, and probe APIs
|
||||
- add probe execution mode, check extraction, and result aggregation
|
||||
- add region affinity, Nodes TUI tab, and probe metrics
|
||||
- add status bar, tab badges, and detail panel
|
||||
- bordered modals, welcome state, and dynamic name width
|
||||
- DOWN-first sort, health pulse, and site filter
|
||||
- split available width evenly between NAME and HISTORY columns
|
||||
- add type icons to sites table
|
||||
- persist logs to DB, load on startup
|
||||
- add incident management and maintenance windows
|
||||
- zebra striping, detail breadcrumb, sparkline stats, collapse persistence
|
||||
- add --version flag with build metadata injection
|
||||
- add theme system with 4 curated palettes
|
||||
- swap light theme for Tokyo Night and Gruvbox
|
||||
- seed SSH users from env var and authorized_keys file (#31)
|
||||
- show error reason when monitors go DOWN
|
||||
- proper push monitor lifecycle — PENDING, LATE, DOWN states
|
||||
- logs tab overhaul — severity tags, filtering, recovery durations
|
||||
- alert channel health indicator + test alerts
|
||||
- add GitHub release relay workflow
|
||||
- classify error reasons on DOWN monitors
|
||||
- add state change history view with outage duration
|
||||
- add Opsgenie provider
|
||||
- add STALE state for push monitors
|
||||
- add SLA reporting view
|
||||
- overhaul latency sparkline scaling, color, and layout
|
||||
- auto-prune expired maintenance windows
|
||||
- click-to-inspect sparkline tooltips in detail view
|
||||
|
||||
### Changed
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### Security
|
||||
- Cluster secret compared with crypto/subtle (timing-safe)
|
||||
- http.MaxBytesReader on all JSON endpoints
|
||||
- ReadHeaderTimeout added to HTTP server
|
||||
- forward all msg types to huh forms, improve row selection UX
|
||||
- harden TLS, timeouts, validation, logging, and token generation
|
||||
- add delete confirm, input validation, XSS fix, history persistence
|
||||
- correctness and robustness fixes across all subsystems
|
||||
- make status bar and tab badges visible
|
||||
- use stable sort to prevent site list shuffling each tick
|
||||
- sort children by ID before status to prevent map-order shuffling
|
||||
- sparkline now spans full column width
|
||||
- sparkline right-aligned — current time at right edge, dots fill left
|
||||
- increase history buffer to 60 so sparkline fills completely
|
||||
- compute uptime from windowed statuses, not running counters
|
||||
- seed status and latency from DB history on startup
|
||||
- strip push tokens from /status/json response
|
||||
- correct viewport sizing and dynamic chrome calculation
|
||||
- constrain form height to terminal and forward resize events
|
||||
- skip children in maintenance when computing group status
|
||||
- exclude maintenance'd monitors from down count and pulse
|
||||
- group selection highlight, layout constants, group history graphs
|
||||
- stable monitor count and universal group icons
|
||||
- replace panic with error return, handle unmarshal errors
|
||||
- add context to Provider.Send, log alert failures
|
||||
- constant-time secret comparison, request size limits
|
||||
- graceful shutdown for HTTP, SSH servers and database
|
||||
- add jitter to check intervals and stagger startup
|
||||
- use sh instead of bash for runner compatibility
|
||||
- enable CGO for race detector, use lint-action v7
|
||||
- install gcc for race detector support
|
||||
- skip irrelevant field validation by monitor type
|
||||
- guard max retries validator for group type
|
||||
- tighten zebra row contrast for Tokyo Night and Gruvbox
|
||||
- phase 1 critical fixes for public release
|
||||
- phase 2 high-severity hardening
|
||||
- phase 3 medium reliability and hardening
|
||||
- phase 4 code quality and low-severity fixes
|
||||
- rename GITEA_TOKEN to RELEASE_TOKEN
|
||||
- remove explicit container, use sh shell
|
||||
- bump golang.org/x/crypto v0.47.0 → v0.52.0
|
||||
- install git and gcc for GoReleaser in release pipeline
|
||||
- use internal Gitea URL for GoReleaser API calls
|
||||
- use docker-builder runner for Docker image builds
|
||||
- patch Docker Scout CVEs and remove unused openssh-client (#41)
|
||||
- non-root user, supply chain attestations, build cleanup
|
||||
- move SSH host key path into /data for non-root user
|
||||
- create .ssh dir explicitly, ensure entrypoint is executable
|
||||
- resolve git-cliff download URL dynamically
|
||||
- extract git-cliff to /tmp to avoid dirty worktree
|
||||
- make Grype CVE scan non-blocking for known wish vuln
|
||||
- bump Go 1.26.3 → 1.26.4
|
||||
- remove error truncation from detail panel
|
||||
- classify safedial "failed to connect" as TCP
|
||||
- resolve staticcheck lint errors in history view
|
||||
- trigger immediate recheck after site config edit
|
||||
- broken tick chain after form/dialog + retries off-by-one
|
||||
- wire up [e] edit key in detail panel
|
||||
- show push token and URL in detail panel
|
||||
- show correct push heartbeat curl command in detail panel
|
||||
- propagate STALE/LATE child status to group
|
||||
- quick wins batch — version footer, column widths, zebra, sparkline
|
||||
- logs tab use viewport for scrollable content
|
||||
- pin footer to bottom of terminal
|
||||
- normalize content whitespace for consistent footer position
|
||||
- clip overflowing content to keep footer pinned
|
||||
- remove extra blank lines above footer
|
||||
- expand log viewport to fill content area
|
||||
- log STALE recovery in push heartbeat handler
|
||||
- check fmt.Sscanf return value (errcheck lint)
|
||||
- inject time into ComputeDailyBreakdown for testability
|
||||
- cascade delete related rows when removing a site
|
||||
- merge check results into live state, never overwrite
|
||||
- serialize DB writes through a single drained writer
|
||||
- close XFF bypass and three secret-leak paths
|
||||
- move blocking DB IO out of Update/View into tea.Cmds
|
||||
- move theme styles onto the Model to end cross-session races
|
||||
- finish moving keypress DB reads into tea.Cmds
|
||||
- move all store writes out of Update into tea.Cmds
|
||||
- mask alert secrets in the TUI detail panel and table
|
||||
- serve /status/json through a public DTO
|
||||
- make SSH key revocation fail closed
|
||||
- six correctness fixes for the state machine
|
||||
- migrate Postgres timestamps to TIMESTAMPTZ
|
||||
- seven quick-win bug fixes across engine, server, TUI, CLI
|
||||
- SSRF guard gaps + DNS port restriction + metrics auth
|
||||
- track selection by site ID + q means back everywhere
|
||||
- apply convergence + push/group check history
|
||||
- Kuma import tokens/paused, Docker hardening, migrate-secrets idempotency
|
||||
- six small fixes — rate limiter leak, DST SLA, probe sort, TUI cleanup
|
||||
- seven fixes — token scan, variadic cleanup, TUI layout, compose secrets
|
||||
- chmod SQLite DB files to 0600 on open
|
||||
- close DNS-rebind TOCTOU on ping/port checks
|
||||
- API import no longer replaces user accounts
|
||||
- email send respects context deadline
|
||||
- rename X-Upkeep-Secret header to X-Uptop-Secret
|
||||
- apply log filter to full log list, not viewport window
|
||||
- repair pipeline defects found in v0.1.0-rc.1 rehearsal
|
||||
- suppress wish GHSA alias in grype, fold rc tags into launch notes
|
||||
- scan gates docker push, rc tags spare :latest, mirror waits for stable assets
|
||||
- remove tagged scan image in cleanup step
|
||||
- exclude rc tags from cliff tag_pattern so launch notes span full history
|
||||
- fall back to embedded build info when ldflags absent
|
||||
- drop body-grep Security grouping, map polish type in cliff
|
||||
- sync selectedID on click so refreshLive doesn't revert cursor
|
||||
- resolve 4 tag-blocking issues for v0.1.0
|
||||
|
||||
## [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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Development
|
||||
|
||||
```sh
|
||||
go run cmd/uptop/main.go -demo # starts with sample data
|
||||
go run ./cmd/uptop -demo # starts with sample data
|
||||
ssh -p 23234 localhost # connect to TUI
|
||||
```
|
||||
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
# --- Stage 1: Builder ---
|
||||
FROM golang:1.26-alpine3.23 AS builder
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY . .
|
||||
ENV CGO_ENABLED=1
|
||||
ENV CGO_ENABLED=0
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=none
|
||||
ARG BUILD_DATE=unknown
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--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/main.go
|
||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop
|
||||
|
||||
# --- Stage 2: Runner ---
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates openssh-client
|
||||
RUN mkdir /data
|
||||
RUN apk add --no-cache ca-certificates && apk upgrade --no-cache
|
||||
RUN addgroup -g 1000 -S uptop && adduser -u 1000 -S uptop -G uptop
|
||||
RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
|
||||
|
||||
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_DSN=/data/uptop.db
|
||||
ENV UPTOP_KEYS=/data/authorized_keys
|
||||
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
||||
ENV UPTOP_PORT=23234
|
||||
|
||||
EXPOSE 23234
|
||||
EXPOSE 8080 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"]
|
||||
@@ -1,51 +1,126 @@
|
||||
# uptop
|
||||
<div align="center">
|
||||
<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>
|
||||
|
||||
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`.
|
||||
<p>
|
||||
<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>
|
||||
|
||||
Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
|
||||
<img src="assets/demo.gif" alt="uptop demo" width="800">
|
||||
</div>
|
||||
|
||||
## What it does
|
||||
## What is this
|
||||
|
||||
- **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
||||
- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
|
||||
- **Config as code**: define monitors in YAML, apply declaratively, version control your setup
|
||||
- **HA clustering**: leader/follower with automatic failover
|
||||
- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
|
||||
- **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
|
||||
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.
|
||||
|
||||
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard.
|
||||
|
||||
Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.com/lerkolabs/uptop) — [GitHub](https://github.com/lerkolabs/uptop) is a mirror; releases are published to both.
|
||||
|
||||
## Features
|
||||
|
||||
- **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
|
||||
|
||||
```bash
|
||||
go run cmd/uptop/main.go
|
||||
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop
|
||||
ssh -p 23234 localhost
|
||||
```
|
||||
|
||||
Seed some demo data to see it in action:
|
||||
Want some data to look at first:
|
||||
|
||||
```bash
|
||||
go run cmd/uptop/main.go -demo
|
||||
UPTOP_ADMIN_KEY="$(cat ~/.ssh/id_ed25519.pub)" go run ./cmd/uptop -demo
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
### From source
|
||||
<details>
|
||||
<summary><strong>Docker (recommended)</strong></summary>
|
||||
|
||||
```bash
|
||||
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
|
||||
```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
|
||||
```
|
||||
|
||||
### Docker
|
||||
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
|
||||
docker pull lerko/uptop:latest
|
||||
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
|
||||
go install gitea.lerkolabs.com/lerkolabs/uptop/cmd/uptop@latest
|
||||
```
|
||||
|
||||
### Binary
|
||||
</details>
|
||||
|
||||
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
|
||||
**Upgrading:** Pull the new image (or binary) and restart. Database migrations run automatically on startup.
|
||||
|
||||
## Config as code
|
||||
|
||||
@@ -63,35 +138,11 @@ uptop apply -f monitors.yaml --dry-run # see what would change
|
||||
uptop apply -f monitors.yaml --prune # delete anything not in the YAML
|
||||
```
|
||||
|
||||
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.
|
||||
Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `UPTOP_PORT` | `23234` | SSH server port |
|
||||
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
|
||||
@@ -99,10 +150,57 @@ First run: attach to the container (`docker attach uptop`), go to the Users tab,
|
||||
| `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
|
||||
| `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
|
||||
| `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
|
||||
| `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
|
||||
| `UPTOP_PEER_URL` | | Leader URL for follower nodes |
|
||||
| `UPTOP_ENCRYPTION_KEY` | | AES-256-GCM key for alert credentials ([details](#encryption)) |
|
||||
| `UPTOP_CLUSTER_MODE` | `leader` | `leader`, `follower`, or `probe` |
|
||||
| `UPTOP_PEER_URL` | | Leader URL for follower and probe nodes |
|
||||
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
||||
| `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
|
||||
|
||||
@@ -110,7 +208,7 @@ Export your Kuma backup JSON, then:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/import/kuma \
|
||||
-H "X-Upkeep-Secret: your-secret" \
|
||||
-H "X-Uptop-Secret: your-secret" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @kuma-backup.json
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" fill="none">
|
||||
<!-- Terminal-inspired text mark for uptop -->
|
||||
<rect width="320" height="80" rx="8" fill="#1C1B1A"/>
|
||||
<!-- Prompt caret -->
|
||||
<text x="16" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#3AA99F">▲</text>
|
||||
<!-- "uptop" in monospace -->
|
||||
<text x="52" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#CECDC3">uptop</text>
|
||||
<!-- Status dot -->
|
||||
<circle cx="296" cy="40" r="6" fill="#879A39"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,51 @@
|
||||
[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 },
|
||||
]
|
||||
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,26 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/cluster"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/config"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/server"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/ssh"
|
||||
@@ -39,8 +40,41 @@ var (
|
||||
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() {
|
||||
log.SetOutput(os.Stderr)
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
if len(os.Args) >= 2 {
|
||||
switch os.Args[1] {
|
||||
@@ -50,23 +84,50 @@ func main() {
|
||||
case "export":
|
||||
runExport(os.Args[2:])
|
||||
return
|
||||
case "version", "--version", "-v":
|
||||
case "version", "--version", "-v", "-version":
|
||||
printVersion()
|
||||
return
|
||||
case "migrate-secrets":
|
||||
runMigrateSecrets(os.Args[2:])
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printUsage()
|
||||
return
|
||||
case "serve":
|
||||
runServe(os.Args[2:])
|
||||
return
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if version == "dev" {
|
||||
fmt.Println("uptop dev")
|
||||
} else {
|
||||
fmt.Printf("uptop %s (%s, %s)\n", version, commit, date)
|
||||
out := "uptop " + version
|
||||
var meta []string
|
||||
if commit != "none" {
|
||||
meta = append(meta, commit)
|
||||
}
|
||||
if date != "unknown" {
|
||||
meta = append(meta, date)
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
out += " (" + strings.Join(meta, ", ") + ")"
|
||||
}
|
||||
fmt.Println(out)
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
@@ -85,7 +146,40 @@ func redactDSN(dsn string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func openStore(dbType, dsn string) store.Store {
|
||||
// parseTrustedProxies turns UPTOP_TRUSTED_PROXIES (comma-separated CIDRs or
|
||||
// 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 err error
|
||||
if dbType == "postgres" {
|
||||
@@ -94,21 +188,21 @@ func openStore(dbType, dsn string) store.Store {
|
||||
ss, err = store.NewSQLiteStore(dsn)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
|
||||
slog.Error("database connection failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
|
||||
enc, err := store.NewEncryptor(encKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
|
||||
slog.Error("encryption key invalid", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ss.SetEncryptor(enc)
|
||||
} else {
|
||||
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
||||
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
|
||||
}
|
||||
if err := ss.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return ss
|
||||
@@ -119,7 +213,7 @@ func runApply(args []string) {
|
||||
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
||||
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
||||
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||
|
||||
@@ -129,20 +223,21 @@ func runApply(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.LoadFile(*filePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("config load failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
changes, err := config.Apply(s, f, config.ApplyOpts{
|
||||
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
|
||||
DryRun: *dryRun,
|
||||
Prune: *prune,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("config apply failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -152,27 +247,28 @@ func runApply(args []string) {
|
||||
func runExport(args []string) {
|
||||
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
||||
outPath := fs.String("o", "-", "Output file path (- for stdout)")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.Export(s)
|
||||
f, err := config.Export(ctx, s)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("export failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := config.WriteFile(f, *outPath); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("export write failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runMigrateSecrets(args []string) {
|
||||
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
@@ -183,10 +279,12 @@ func runMigrateSecrets(args []string) {
|
||||
}
|
||||
enc, err := store.NewEncryptor(encKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("encryption key invalid", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var ss *store.SQLStore
|
||||
if *dbType == "postgres" {
|
||||
ss, err = store.NewPostgresStore(*dsn)
|
||||
@@ -194,25 +292,25 @@ func runMigrateSecrets(args []string) {
|
||||
ss, err = store.NewSQLiteStore(*dsn)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
|
||||
slog.Error("database connection failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ss.Init(); err != nil {
|
||||
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)
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ss.SetEncryptor(enc)
|
||||
|
||||
alerts, err := ss.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to load alerts", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
migrated := 0
|
||||
for _, a := range alerts {
|
||||
if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err)
|
||||
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
slog.Error("alert migration failed", "alert", a.Name, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
migrated++
|
||||
@@ -221,64 +319,19 @@ func runMigrateSecrets(args []string) {
|
||||
}
|
||||
|
||||
func runServe(args []string) {
|
||||
portVal := 23234
|
||||
dbType := "sqlite"
|
||||
dbDSN := "uptop.db"
|
||||
httpPort := 8080
|
||||
enableStatus := false
|
||||
statusTitle := "System Status"
|
||||
clusterMode := "leader"
|
||||
clusterPeer := ""
|
||||
clusterKey := ""
|
||||
cfg := parseConfig()
|
||||
|
||||
if v := os.Getenv("UPTOP_PORT"); v != "" {
|
||||
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 == "" {
|
||||
if cfg.ClusterMode == "probe" {
|
||||
if cfg.NodeID == "" {
|
||||
fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
|
||||
os.Exit(1)
|
||||
}
|
||||
if clusterPeer == "" {
|
||||
if cfg.PeerURL == "" {
|
||||
fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion)
|
||||
fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", cfg.NodeID, cfg.NodeRegion)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
@@ -289,29 +342,28 @@ func runServe(args []string) {
|
||||
cancel()
|
||||
}()
|
||||
|
||||
probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
|
||||
if probeAllowPrivate {
|
||||
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
|
||||
if cfg.AllowPrivateTargets {
|
||||
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks")
|
||||
}
|
||||
|
||||
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
|
||||
NodeID: nodeID,
|
||||
NodeName: nodeName,
|
||||
Region: nodeRegion,
|
||||
LeaderURL: clusterPeer,
|
||||
SharedKey: clusterKey,
|
||||
NodeID: cfg.NodeID,
|
||||
NodeName: cfg.NodeName,
|
||||
Region: cfg.NodeRegion,
|
||||
LeaderURL: cfg.PeerURL,
|
||||
SharedKey: cfg.ClusterSecret,
|
||||
Interval: 30,
|
||||
AllowPrivateTargets: probeAllowPrivate,
|
||||
AllowPrivateTargets: cfg.AllowPrivateTargets,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
|
||||
slog.Error("probe failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := fs.Int("port", portVal, "SSH Port")
|
||||
flagDBType := fs.String("db-type", dbType, "Database type")
|
||||
flagDSN := fs.String("dsn", dbDSN, "Database DSN")
|
||||
port := fs.Int("port", cfg.Port, "SSH Port")
|
||||
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)")
|
||||
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
||||
demo := fs.Bool("demo", false, "Seed demo data")
|
||||
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||
@@ -320,101 +372,92 @@ func runServe(args []string) {
|
||||
var dbErr error
|
||||
if *flagDBType == "postgres" {
|
||||
ss, dbErr = store.NewPostgresStore(*flagDSN)
|
||||
fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
|
||||
slog.Info("database connected", "type", "postgres", "dsn", redactDSN(*flagDSN))
|
||||
} else {
|
||||
ss, dbErr = store.NewSQLiteStore(*flagDSN)
|
||||
fmt.Printf("Using SQLite: %s\n", *flagDSN)
|
||||
slog.Info("database connected", "type", "sqlite", "dsn", *flagDSN)
|
||||
}
|
||||
if dbErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
|
||||
slog.Error("database connection failed", "err", dbErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer ss.Close()
|
||||
|
||||
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
|
||||
enc, err := store.NewEncryptor(encKey)
|
||||
if cfg.EncryptionKey != "" {
|
||||
enc, err := store.NewEncryptor(cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
|
||||
slog.Error("encryption key invalid", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ss.SetEncryptor(enc)
|
||||
} else {
|
||||
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
||||
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
|
||||
}
|
||||
|
||||
var s store.Store = ss
|
||||
if err := s.Init(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
||||
kc := newKeyCache(ss)
|
||||
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
|
||||
|
||||
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)
|
||||
}
|
||||
if *demo {
|
||||
seedDemoData(s)
|
||||
seedDemoData(ctx, s)
|
||||
}
|
||||
|
||||
seedKeysFromEnv(s)
|
||||
seedKeysFromEnv(ctx, s)
|
||||
|
||||
if *importKuma != "" {
|
||||
kb, err := importer.LoadKumaFile(*importKuma)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
|
||||
slog.Error("kuma import failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
backup := importer.ConvertKuma(kb)
|
||||
if err := s.ImportData(backup); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
|
||||
if err := s.ImportData(ctx, backup); err != nil {
|
||||
slog.Error("import failed", "err", err)
|
||||
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)
|
||||
}
|
||||
|
||||
allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
|
||||
if allowPrivate {
|
||||
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
|
||||
if cfg.AllowPrivateTargets {
|
||||
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks")
|
||||
}
|
||||
|
||||
eng := monitor.NewEngineWithOpts(s, allowPrivate)
|
||||
if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
|
||||
eng := monitor.NewEngineWithOpts(s, cfg.AllowPrivateTargets)
|
||||
if cfg.InsecureSkipVerify {
|
||||
eng.SetInsecureSkipVerify(true)
|
||||
}
|
||||
if aggStrategy != "" {
|
||||
eng.SetAggStrategy(monitor.AggregationStrategy(aggStrategy))
|
||||
if cfg.AggStrategy != "" {
|
||||
eng.SetAggStrategy(monitor.AggregationStrategy(cfg.AggStrategy))
|
||||
}
|
||||
eng.SetMaintRetention(cfg.MaintRetention)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
eng.InitHistory()
|
||||
eng.InitLogs()
|
||||
eng.InitHistory(ctx)
|
||||
eng.InitLogs(ctx)
|
||||
eng.InitAlertHealth(ctx)
|
||||
eng.Start(ctx)
|
||||
|
||||
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
||||
tlsKey := os.Getenv("UPTOP_TLS_KEY")
|
||||
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
|
||||
|
||||
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)
|
||||
httpSrv := server.Start(cfg.serverConfig(localTUI), s, eng)
|
||||
|
||||
cluster.Start(ctx, cluster.Config{
|
||||
Mode: clusterMode,
|
||||
PeerURL: clusterPeer,
|
||||
SharedKey: clusterKey,
|
||||
Mode: cfg.ClusterMode,
|
||||
PeerURL: cfg.PeerURL,
|
||||
SharedKey: cfg.ClusterSecret,
|
||||
}, eng)
|
||||
|
||||
kc := newKeyCache(s)
|
||||
sshSrv := startSSHServer(*port, s, eng, kc)
|
||||
|
||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if localTUI {
|
||||
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
slog.Error("TUI failed", "err", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("uptop running in HEADLESS mode")
|
||||
@@ -425,16 +468,18 @@ func runServe(args []string) {
|
||||
}
|
||||
cancel()
|
||||
|
||||
eng.Stop()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
if httpSrv != nil {
|
||||
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP shutdown error: %v", err)
|
||||
slog.Error("HTTP shutdown failed", "err", err)
|
||||
}
|
||||
}
|
||||
if sshSrv != nil {
|
||||
if err := sshSrv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("SSH shutdown error: %v", err)
|
||||
slog.Error("SSH shutdown failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,57 +489,57 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
|
||||
wish.WithAddress(fmt.Sprintf(":%d", port)),
|
||||
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
|
||||
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return kc.IsAllowed(key)
|
||||
return kc.IsAllowed(ctx, key)
|
||||
}),
|
||||
wish.WithMiddleware(
|
||||
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
return tui.InitialModel(false, db, eng), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
}),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
|
||||
slog.Error("SSH server failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
|
||||
log.Printf("SSH server error: %v", err)
|
||||
slog.Error("SSH server failed", "err", err)
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
func seedDemoData(s store.Store) {
|
||||
existing, _ := s.GetSites()
|
||||
func seedDemoData(ctx context.Context, s store.Store) {
|
||||
existing, _ := s.GetSites(ctx)
|
||||
if len(existing) > 0 {
|
||||
return
|
||||
}
|
||||
fmt.Println("Seeding demo data...")
|
||||
|
||||
if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil {
|
||||
log.Printf("demo seed: add alert: %v", err)
|
||||
if err := s.AddAlert(ctx, "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)
|
||||
return
|
||||
}
|
||||
if err := s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil {
|
||||
log.Printf("demo seed: add alert: %v", err)
|
||||
if err := s.AddAlert(ctx, "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)
|
||||
return
|
||||
}
|
||||
if err := s.AddAlert("Email Oncall", "email", map[string]string{
|
||||
if err := s.AddAlert(ctx, "Email Oncall", "email", map[string]string{
|
||||
"host": "smtp.example.com", "port": "587",
|
||||
"user": "oncall@example.com", "pass": "replace-me",
|
||||
"from": "oncall@example.com", "to": "team@example.com",
|
||||
}); err != nil {
|
||||
log.Printf("demo seed: add alert: %v", err)
|
||||
slog.Error("demo seed failed", "step", "add alert", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
alerts, _ := s.GetAllAlerts()
|
||||
alerts, _ := s.GetAllAlerts(ctx)
|
||||
alertID := 0
|
||||
if len(alerts) > 0 {
|
||||
alertID = alerts[0].ID
|
||||
}
|
||||
|
||||
demoSites := []models.Site{
|
||||
demoSites := []models.SiteConfig{
|
||||
{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: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1},
|
||||
@@ -507,8 +552,8 @@ func seedDemoData(s store.Store) {
|
||||
{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7},
|
||||
}
|
||||
for _, site := range demoSites {
|
||||
if err := s.AddSite(site); err != nil {
|
||||
log.Printf("demo seed: add site %q: %v", site.Name, err)
|
||||
if err := s.AddSite(ctx, site); err != nil {
|
||||
slog.Error("demo seed failed", "step", "add site", "site", site.Name, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,9 +570,13 @@ func newKeyCache(db store.Store) *keyCache {
|
||||
return &keyCache{db: db, ttl: 30 * time.Second}
|
||||
}
|
||||
|
||||
func (c *keyCache) refresh() {
|
||||
users, err := c.db.GetAllUsers()
|
||||
func (c *keyCache) refresh(ctx context.Context) {
|
||||
users, err := c.db.GetAllUsers(ctx)
|
||||
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
|
||||
}
|
||||
keys := make([]ssh.PublicKey, 0, len(users))
|
||||
@@ -544,19 +593,24 @@ func (c *keyCache) refresh() {
|
||||
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() {
|
||||
c.mu.Lock()
|
||||
c.keys = nil
|
||||
c.updated = time.Time{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
||||
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool {
|
||||
c.mu.RLock()
|
||||
stale := time.Since(c.updated) > c.ttl
|
||||
c.mu.RUnlock()
|
||||
|
||||
if stale {
|
||||
c.refresh()
|
||||
c.refresh(ctx)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
@@ -569,7 +623,40 @@ func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func seedKeysFromEnv(s store.Store) {
|
||||
// userInvalidatingStore drops the SSH key cache whenever the user table
|
||||
// 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
|
||||
|
||||
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
||||
@@ -578,7 +665,9 @@ func seedKeysFromEnv(s store.Store) {
|
||||
|
||||
if path := os.Getenv("UPTOP_KEYS"); 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)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
@@ -595,9 +684,9 @@ func seedKeysFromEnv(s store.Store) {
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := s.GetAllUsers()
|
||||
existing, err := s.GetAllUsers(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not check existing users: %v\n", err)
|
||||
slog.Warn("could not check existing users", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -613,8 +702,8 @@ func seedKeysFromEnv(s store.Store) {
|
||||
}
|
||||
|
||||
username := usernameFromKey(key, i, len(existing)+added)
|
||||
if err := s.AddUser(username, key, "admin"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to seed user %q: %v\n", username, err)
|
||||
if err := s.AddUser(ctx, username, key, "admin"); err != nil {
|
||||
slog.Warn("failed to seed user", "user", username, "err", err) //nolint:gosec // structured slog, not format string
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != ""))
|
||||
|
||||
@@ -3,8 +3,10 @@ services:
|
||||
# LEADER NODE
|
||||
# -------------------------
|
||||
leader:
|
||||
build: .
|
||||
image: lerkolabs/uptop:latest
|
||||
container_name: uptop-leader
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
ports:
|
||||
- "23234:23234" # SSH
|
||||
- "8080:8080" # HTTP
|
||||
@@ -18,7 +20,7 @@ services:
|
||||
|
||||
# Cluster Config
|
||||
- UPTOP_CLUSTER_MODE=leader
|
||||
- UPTOP_CLUSTER_SECRET=mysecret
|
||||
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use
|
||||
depends_on:
|
||||
- leader-db
|
||||
stdin_open: true
|
||||
@@ -38,8 +40,10 @@ services:
|
||||
# FOLLOWER NODE
|
||||
# -------------------------
|
||||
follower:
|
||||
build: .
|
||||
image: lerkolabs/uptop:latest
|
||||
container_name: uptop-follower
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
ports:
|
||||
- "23233:23234" # SSH (Mapped to different host port)
|
||||
- "8081:8080" # HTTP (Mapped to different host port)
|
||||
@@ -53,7 +57,7 @@ services:
|
||||
|
||||
# Cluster Config
|
||||
- UPTOP_CLUSTER_MODE=follower
|
||||
- UPTOP_CLUSTER_SECRET=mysecret
|
||||
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use
|
||||
# IMPORTANT: Uses the Service Name "leader" to connect internally
|
||||
- UPTOP_PEER_URL=http://leader:8080
|
||||
depends_on:
|
||||
@@ -1,8 +1,8 @@
|
||||
services:
|
||||
# The Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
container_name: uptop-dev
|
||||
ports:
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- UPTOP_DB_TYPE=postgres
|
||||
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
||||
|
||||
# --- Web Server Configuration (Phase 4) ---
|
||||
# --- Web Server Configuration ---
|
||||
- UPTOP_HTTP_PORT=8080
|
||||
- UPTOP_STATUS_ENABLED=true
|
||||
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
||||
@@ -1,9 +1,11 @@
|
||||
services:
|
||||
leader:
|
||||
build: .
|
||||
image: lerkolabs/uptop:latest
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=leader
|
||||
- UPTOP_CLUSTER_SECRET=changeme
|
||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||
- UPTOP_AGG_STRATEGY=any-down
|
||||
- UPTOP_STATUS_ENABLED=true
|
||||
ports:
|
||||
@@ -11,25 +13,29 @@ services:
|
||||
- "23234:23234"
|
||||
|
||||
probe-us-east:
|
||||
build: .
|
||||
image: lerkolabs/uptop:latest
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=probe
|
||||
- UPTOP_NODE_ID=us-east-1
|
||||
- UPTOP_NODE_NAME=US East Probe
|
||||
- UPTOP_NODE_REGION=us-east
|
||||
- UPTOP_PEER_URL=http://leader:8080
|
||||
- UPTOP_CLUSTER_SECRET=changeme
|
||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||
depends_on:
|
||||
- leader
|
||||
|
||||
probe-eu-west:
|
||||
build: .
|
||||
image: lerkolabs/uptop:latest
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=probe
|
||||
- UPTOP_NODE_ID=eu-west-1
|
||||
- UPTOP_NODE_NAME=EU West Probe
|
||||
- UPTOP_NODE_REGION=eu-west
|
||||
- UPTOP_PEER_URL=http://leader:8080
|
||||
- UPTOP_CLUSTER_SECRET=changeme
|
||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||
depends_on:
|
||||
- leader
|
||||
@@ -1,10 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: lerkolabs/uptop:latest
|
||||
container_name: uptop
|
||||
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:
|
||||
- "23234:23234"
|
||||
- "8080:8080"
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/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 "$@"
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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.
|
||||
@@ -122,7 +122,7 @@ Groups can't nest inside other groups. A group is healthy when all its children
|
||||
|
||||
## Alert types
|
||||
|
||||
All 9 providers work in the YAML. The `settings` map is different per type.
|
||||
All 10 providers work in the YAML. The `settings` map is different per type.
|
||||
|
||||
```yaml
|
||||
# Discord / Slack / Generic Webhook — just a URL
|
||||
@@ -149,6 +149,9 @@ All 9 providers work in the YAML. The `settings` map is different per type.
|
||||
url: https://ntfy.sh
|
||||
topic: my-alerts
|
||||
priority: "4"
|
||||
# for protected topics:
|
||||
# username: user
|
||||
# password: pass
|
||||
|
||||
# Telegram
|
||||
- name: Telegram Ops
|
||||
@@ -178,6 +181,14 @@ All 9 providers work in the YAML. The `settings` map is different per type.
|
||||
url: https://gotify.example.com
|
||||
token: app-token
|
||||
priority: "8"
|
||||
|
||||
# Opsgenie
|
||||
- name: Opsgenie
|
||||
type: opsgenie
|
||||
settings:
|
||||
api_key: your-api-key
|
||||
priority: P2 # P1–P5, default P3
|
||||
# eu: "true" # use the EU API endpoint
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -224,7 +235,25 @@ Monitors and alerts are matched by **name**. Names must be unique across the ent
|
||||
|
||||
Apply is idempotent. Run it twice with the same file, second run changes nothing.
|
||||
|
||||
If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
|
||||
Apply is **not atomic** — items are written one at a time, so an error mid-apply (bad value, lost DB connection, ctrl-C) leaves the items already written in place. That's safe to recover from: apply diffs against the database by name, so fix the issue and run it again — it converges the rest. Just don't run two applies against the same database at once.
|
||||
|
||||
## Backups and secrets
|
||||
|
||||
`uptop export` writes alert credentials (SMTP passwords, API tokens, webhook URLs) into the YAML in clear text — that's what makes the file restorable. Treat it like a secrets file.
|
||||
|
||||
The HTTP export endpoint redacts those same fields **by default**:
|
||||
|
||||
```bash
|
||||
# secrets show as ***REDACTED*** — fine for sharing or review
|
||||
curl -H "X-Uptop-Secret: your-secret" \
|
||||
"http://localhost:8080/api/backup/export"
|
||||
|
||||
# full backup you can actually restore from
|
||||
curl -H "X-Uptop-Secret: your-secret" \
|
||||
"http://localhost:8080/api/backup/export?redact_secrets=false"
|
||||
```
|
||||
|
||||
Restoring a redacted export imports the literal string `***REDACTED***` as your credentials. For real backups, pass `redact_secrets=false` or run `uptop export` on the host.
|
||||
|
||||
## Typical workflow
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module gitea.lerkolabs.com/lerko/uptop
|
||||
module gitea.lerkolabs.com/lerkolabs/uptop
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
@@ -13,10 +13,11 @@ require (
|
||||
github.com/lib/pq v1.11.1
|
||||
github.com/lrstanley/bubblezone v1.0.0
|
||||
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/prometheus-community/pro-bing v0.8.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.52.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -48,14 +49,18 @@ require (
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // 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/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/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.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
|
||||
)
|
||||
|
||||
@@ -64,8 +64,12 @@ 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
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/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/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
|
||||
@@ -78,8 +82,6 @@ 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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
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/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
@@ -90,10 +92,14 @@ 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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
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/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/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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
@@ -107,8 +113,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/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
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/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -125,3 +131,31 @@ 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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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=
|
||||
|
||||
@@ -3,18 +3,40 @@ package alert
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||
const alertHTTPTimeout = 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 {
|
||||
Send(ctx context.Context, title, message string) error
|
||||
@@ -43,7 +65,7 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
|
||||
}
|
||||
resp, err := alertClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return sanitizeError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
@@ -110,6 +132,24 @@ 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 {
|
||||
switch cfg.Type {
|
||||
case "discord":
|
||||
@@ -173,6 +213,20 @@ func GetProvider(cfg models.AlertConfig) Provider {
|
||||
Payload: gotifyPayload(priority),
|
||||
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:
|
||||
return nil
|
||||
}
|
||||
@@ -194,7 +248,6 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
|
||||
to := sanitizeHeader(e.To)
|
||||
from := sanitizeHeader(e.From)
|
||||
subject := sanitizeHeader(title)
|
||||
@@ -206,7 +259,67 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
||||
"\r\n" +
|
||||
body + "\r\n")
|
||||
return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
|
||||
return sendMailContext(ctx, e.Host, e.Port, e.User, e.Pass, 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 {
|
||||
@@ -230,7 +343,7 @@ func (n *NtfyProvider) Send(ctx context.Context, title, message string) error {
|
||||
}
|
||||
resp, err := alertClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return sanitizeError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package alert
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestHTTPProviderDiscord(t *testing.T) {
|
||||
@@ -196,8 +203,76 @@ 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) {
|
||||
for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} {
|
||||
for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify", "opsgenie"} {
|
||||
p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{
|
||||
"token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost",
|
||||
}})
|
||||
@@ -230,3 +305,145 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -38,28 +38,34 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||
// "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) {
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
client := http.Client{Timeout: followerTimeout}
|
||||
failures := 0
|
||||
threshold := 3
|
||||
threshold := leaderFailureThreshold
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-time.After(followerRetryInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
|
||||
if cfg.SharedKey != "" {
|
||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
||||
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
isLeaderHealthy := false
|
||||
|
||||
if err == nil {
|
||||
isLeaderHealthy = resp.StatusCode == 200
|
||||
isLeaderHealthy = resp.StatusCode == http.StatusOK
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,65 +10,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
|
||||
)
|
||||
|
||||
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
||||
|
||||
type mockStore struct {
|
||||
sites []models.Site
|
||||
storetest.BaseMock
|
||||
}
|
||||
|
||||
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) Close() error { return nil }
|
||||
|
||||
// --- Cluster Start Tests ---
|
||||
|
||||
func TestStart_LeaderMode(t *testing.T) {
|
||||
@@ -163,7 +113,7 @@ func TestFollowerLoop_SendsSecret(t *testing.T) {
|
||||
var receivedSecret string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
receivedSecret = r.Header.Get("X-Upkeep-Secret")
|
||||
receivedSecret = r.Header.Get("X-Uptop-Secret")
|
||||
mu.Unlock()
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte("OK"))
|
||||
@@ -253,7 +203,7 @@ func TestProbeRegister_Failure(t *testing.T) {
|
||||
func TestProbeFetchAssignments_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string][]models.Site{
|
||||
"sites": {{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}},
|
||||
"sites": {{SiteConfig: models.SiteConfig{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}}},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
@@ -290,8 +240,8 @@ func TestProbeExecuteChecks(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
sites := []models.Site{
|
||||
{ID: 1, Type: "http", URL: srv.URL},
|
||||
{ID: 2, Type: "http", URL: srv.URL},
|
||||
{SiteConfig: models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}},
|
||||
{SiteConfig: models.SiteConfig{ID: 2, Type: "http", URL: srv.URL}},
|
||||
}
|
||||
|
||||
strict := &http.Client{}
|
||||
@@ -327,7 +277,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
|
||||
|
||||
var sites []models.Site
|
||||
for i := 0; i < 20; i++ {
|
||||
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
|
||||
sites = append(sites, models.Site{SiteConfig: models.SiteConfig{ID: i + 1, Type: "http", URL: srv.URL}})
|
||||
}
|
||||
|
||||
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
type ProbeConfig struct {
|
||||
@@ -26,12 +26,18 @@ type ProbeConfig struct {
|
||||
AllowPrivateTargets bool
|
||||
}
|
||||
|
||||
const (
|
||||
probeMinInterval = 10
|
||||
probeDefaultInterval = 30
|
||||
probeAPITimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||
if cfg.Interval < 10 {
|
||||
cfg.Interval = 30
|
||||
if cfg.Interval < probeMinInterval {
|
||||
cfg.Interval = probeDefaultInterval
|
||||
}
|
||||
|
||||
apiClient := &http.Client{Timeout: 10 * time.Second}
|
||||
apiClient := &http.Client{Timeout: probeAPITimeout}
|
||||
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
||||
strictClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -47,7 +53,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||
}
|
||||
|
||||
if err := probeRegister(ctx, apiClient, cfg); err != nil {
|
||||
log.Printf("Probe: initial registration failed: %v (will retry)", err)
|
||||
slog.Error("probe initial registration failed", "err", err)
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -59,7 +65,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||
|
||||
sites, err := probeFetchAssignments(ctx, apiClient, cfg)
|
||||
if err != nil {
|
||||
log.Printf("Probe: failed to fetch assignments: %v", err)
|
||||
slog.Error("probe failed to fetch assignments", "err", err)
|
||||
sleepCtx(ctx, 10*time.Second)
|
||||
continue
|
||||
}
|
||||
@@ -73,7 +79,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||
|
||||
if len(results) > 0 {
|
||||
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
|
||||
log.Printf("Probe: failed to report results: %v", err)
|
||||
slog.Error("probe failed to report results", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +96,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
||||
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -108,7 +114,7 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
||||
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -127,9 +133,10 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
|
||||
}
|
||||
|
||||
type probeResultItem struct {
|
||||
SiteID int `json:"site_id"`
|
||||
LatencyNs int64 `json:"latency_ns"`
|
||||
IsUp bool `json:"is_up"`
|
||||
SiteID int `json:"site_id"`
|
||||
LatencyNs int64 `json:"latency_ns"`
|
||||
IsUp bool `json:"is_up"`
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
}
|
||||
|
||||
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem {
|
||||
@@ -151,12 +158,13 @@ loop:
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
|
||||
cr := monitor.RunCheck(ctx, s.SiteConfig, strict, insecure, false, allowPrivate)
|
||||
mu.Lock()
|
||||
results = append(results, probeResultItem{
|
||||
SiteID: s.ID,
|
||||
LatencyNs: cr.LatencyNs,
|
||||
IsUp: cr.Status == "UP",
|
||||
SiteID: s.ID,
|
||||
LatencyNs: cr.LatencyNs,
|
||||
IsUp: cr.Status == string(models.StatusUp),
|
||||
ErrorReason: cr.ErrorReason,
|
||||
})
|
||||
mu.Unlock()
|
||||
}(site)
|
||||
@@ -178,7 +186,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
||||
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -187,7 +195,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("results returned %d", resp.StatusCode)
|
||||
}
|
||||
fmt.Printf("Probe: reported %d check results\n", len(results))
|
||||
slog.Info("probe reported check results", "count", len(results))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
type ApplyOpts struct {
|
||||
@@ -20,17 +22,17 @@ type Change struct {
|
||||
Details string
|
||||
}
|
||||
|
||||
func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
if err := Validate(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingAlerts, err := s.GetAllAlerts()
|
||||
existingAlerts, err := s.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load alerts: %w", err)
|
||||
}
|
||||
|
||||
existingSites, err := s.GetSites()
|
||||
existingSites, err := s.GetSites(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sites: %w", err)
|
||||
}
|
||||
@@ -40,7 +42,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
existingAlertsByName[a.Name] = a
|
||||
}
|
||||
|
||||
existingSitesByName := make(map[string]models.Site, len(existingSites))
|
||||
existingSitesByName := make(map[string]models.SiteConfig, len(existingSites))
|
||||
for _, s := range existingSites {
|
||||
existingSitesByName[s.Name] = s
|
||||
}
|
||||
@@ -52,6 +54,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
alertMap[ea.Name] = ea.ID
|
||||
}
|
||||
|
||||
nextPlaceholderID := -1
|
||||
desiredAlertNames := make(map[string]bool, len(f.Alerts))
|
||||
for _, a := range f.Alerts {
|
||||
desiredAlertNames[a.Name] = true
|
||||
@@ -59,18 +62,21 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
if !exists {
|
||||
changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type})
|
||||
if !opts.DryRun {
|
||||
id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings)
|
||||
id, err := s.AddAlertReturningID(ctx, a.Name, a.Type, a.Settings)
|
||||
if err != nil {
|
||||
return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
|
||||
}
|
||||
alertMap[a.Name] = id
|
||||
} else {
|
||||
alertMap[a.Name] = nextPlaceholderID
|
||||
nextPlaceholderID--
|
||||
}
|
||||
} else {
|
||||
alertMap[a.Name] = existing.ID
|
||||
if diff := diffAlert(existing, a); diff != "" {
|
||||
changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff})
|
||||
if !opts.DryRun {
|
||||
if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
if err := s.UpdateAlert(ctx, existing.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
return changes, fmt.Errorf("update alert %q: %w", a.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -102,11 +108,14 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
if !exists {
|
||||
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"})
|
||||
if !opts.DryRun {
|
||||
id, err := s.AddSiteReturningID(site)
|
||||
id, err := s.AddSiteReturningID(ctx, site)
|
||||
if err != nil {
|
||||
return changes, fmt.Errorf("create group %q: %w", g.Name, err)
|
||||
}
|
||||
groupMap[g.Name] = id
|
||||
} else {
|
||||
groupMap[g.Name] = nextPlaceholderID
|
||||
nextPlaceholderID--
|
||||
}
|
||||
} else {
|
||||
groupMap[g.Name] = existing.ID
|
||||
@@ -114,7 +123,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
if diff := diffSite(normalizeSite(existing), site); diff != "" {
|
||||
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff})
|
||||
if !opts.DryRun {
|
||||
if err := s.UpdateSite(site); err != nil {
|
||||
if err := s.UpdateSite(ctx, site); err != nil {
|
||||
return changes, fmt.Errorf("update group %q: %w", g.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +134,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
for _, g := range groups {
|
||||
parentID := groupMap[g.Name]
|
||||
for _, child := range g.Monitors {
|
||||
c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
|
||||
c, err := applyMonitor(ctx, s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
|
||||
if err != nil {
|
||||
return changes, err
|
||||
}
|
||||
@@ -134,7 +143,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
}
|
||||
|
||||
for _, m := range topLevel {
|
||||
c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun)
|
||||
c, err := applyMonitor(ctx, s, m, alertMap, existingSitesByName, 0, opts.DryRun)
|
||||
if err != nil {
|
||||
return changes, err
|
||||
}
|
||||
@@ -155,7 +164,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
childDeletes = append(childDeletes, c)
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := s.DeleteSite(es.ID); err != nil {
|
||||
if err := s.DeleteSite(ctx, es.ID); err != nil {
|
||||
return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -169,7 +178,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
}
|
||||
changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type})
|
||||
if !opts.DryRun {
|
||||
if err := s.DeleteAlert(ea.ID); err != nil {
|
||||
if err := s.DeleteAlert(ctx, ea.ID); err != nil {
|
||||
return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +188,7 @@ func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) {
|
||||
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) {
|
||||
alertID, err := resolveAlertID(alertMap, m.Alert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("monitor %q: %w", m.Name, err)
|
||||
@@ -191,7 +200,7 @@ func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing ma
|
||||
if !exists {
|
||||
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type})
|
||||
if !dryRun {
|
||||
if _, err := s.AddSiteReturningID(site); err != nil {
|
||||
if _, err := s.AddSiteReturningID(ctx, site); err != nil {
|
||||
return changes, fmt.Errorf("create monitor %q: %w", m.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +209,7 @@ func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing ma
|
||||
if diff := diffSite(normalizeSite(ex), site); diff != "" {
|
||||
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff})
|
||||
if !dryRun {
|
||||
if err := s.UpdateSite(site); err != nil {
|
||||
if err := s.UpdateSite(ctx, site); err != nil {
|
||||
return changes, fmt.Errorf("update monitor %q: %w", m.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -220,8 +229,8 @@ func resolveAlertID(alertMap map[string]int, name string) (int, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func monitorToSite(m Monitor, alertID, parentID int) models.Site {
|
||||
s := models.Site{
|
||||
func monitorToSite(m Monitor, alertID, parentID int) models.SiteConfig {
|
||||
s := models.SiteConfig{
|
||||
Name: m.Name,
|
||||
Type: m.Type,
|
||||
URL: m.URL,
|
||||
@@ -267,7 +276,7 @@ func collectMonitorNames(monitors []Monitor, names map[string]bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSite(s models.Site) models.Site {
|
||||
func normalizeSite(s models.SiteConfig) models.SiteConfig {
|
||||
if s.Method == "" {
|
||||
s.Method = "GET"
|
||||
}
|
||||
@@ -291,7 +300,7 @@ func diffAlert(existing models.AlertConfig, desired Alert) string {
|
||||
return strings.Join(diffs, ", ")
|
||||
}
|
||||
|
||||
func diffSite(existing, desired models.Site) string {
|
||||
func diffSite(existing, desired models.SiteConfig) string {
|
||||
var diffs []string
|
||||
if existing.URL != desired.URL {
|
||||
diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) store.Store {
|
||||
@@ -13,7 +15,7 @@ func newTestStore(t *testing.T) store.Store {
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
if err := s.Init(); err != nil {
|
||||
if err := s.Init(context.Background()); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
return s
|
||||
@@ -31,7 +33,7 @@ func TestApplyCreateFromScratch(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -46,12 +48,12 @@ func TestApplyCreateFromScratch(t *testing.T) {
|
||||
t.Fatalf("expected 3 creates, got %d", creates)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
if len(sites) != 2 {
|
||||
t.Fatalf("expected 2 sites, got %d", len(sites))
|
||||
}
|
||||
|
||||
alerts, _ := s.GetAllAlerts()
|
||||
alerts, _ := s.GetAllAlerts(context.Background())
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
@@ -68,11 +70,11 @@ func TestApplyIdempotent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
|
||||
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil {
|
||||
t.Fatalf("first Apply: %v", err)
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("second Apply: %v", err)
|
||||
}
|
||||
@@ -90,12 +92,12 @@ func TestApplyUpdate(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
|
||||
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil {
|
||||
t.Fatalf("first Apply: %v", err)
|
||||
}
|
||||
|
||||
f.Monitors[0].Interval = 60
|
||||
changes, err := Apply(s, f, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("second Apply: %v", err)
|
||||
}
|
||||
@@ -104,7 +106,7 @@ func TestApplyUpdate(t *testing.T) {
|
||||
t.Fatalf("expected 1 update, got %+v", changes)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
if sites[0].Interval != 60 {
|
||||
t.Fatalf("expected interval 60, got %d", sites[0].Interval)
|
||||
}
|
||||
@@ -112,8 +114,8 @@ func TestApplyUpdate(t *testing.T) {
|
||||
|
||||
func TestApplyPrune(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.AddSite(models.Site{Name: "Keep", URL: "https://keep.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"})
|
||||
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(context.Background(), models.SiteConfig{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
|
||||
|
||||
f := &File{
|
||||
Monitors: []Monitor{
|
||||
@@ -121,7 +123,7 @@ func TestApplyPrune(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{Prune: true})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{Prune: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -136,7 +138,7 @@ func TestApplyPrune(t *testing.T) {
|
||||
t.Fatalf("expected 1 delete, got %d", deleteCount)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
if len(sites) != 1 || sites[0].Name != "Keep" {
|
||||
t.Fatalf("expected only 'Keep', got %+v", sites)
|
||||
}
|
||||
@@ -150,7 +152,7 @@ func TestApplyDryRun(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{DryRun: true})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -159,7 +161,7 @@ func TestApplyDryRun(t *testing.T) {
|
||||
t.Fatalf("expected 1 create in dry-run, got %+v", changes)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
if len(sites) != 0 {
|
||||
t.Fatalf("expected 0 sites after dry-run, got %d", len(sites))
|
||||
}
|
||||
@@ -179,7 +181,7 @@ func TestApplyGroupHierarchy(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -188,8 +190,8 @@ func TestApplyGroupHierarchy(t *testing.T) {
|
||||
t.Fatalf("expected 3 creates, got %d", len(changes))
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
var group models.Site
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
var group models.SiteConfig
|
||||
for _, s := range sites {
|
||||
if s.Type == "group" {
|
||||
group = s
|
||||
@@ -223,12 +225,12 @@ func TestApplyAlertReference(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
|
||||
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
alerts, _ := s.GetAllAlerts()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
alerts, _ := s.GetAllAlerts(context.Background())
|
||||
|
||||
if sites[0].AlertID != alerts[0].ID {
|
||||
t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID)
|
||||
@@ -243,7 +245,7 @@ func TestApplyInvalidAlertRef(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Apply(s, f, ApplyOpts{})
|
||||
_, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("expected alert not found error, got %v", err)
|
||||
}
|
||||
@@ -258,15 +260,83 @@ func TestApplyDuplicateNames(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Apply(s, f, ApplyOpts{})
|
||||
_, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
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) {
|
||||
s := newTestStore(t)
|
||||
s.AddAlert("Existing", "webhook", map[string]string{"url": "https://example.com"})
|
||||
s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"})
|
||||
|
||||
f := &File{
|
||||
Monitors: []Monitor{
|
||||
@@ -274,7 +344,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
changes, err := Apply(s, f, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s, f, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -283,7 +353,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
|
||||
t.Fatalf("expected 1 create, got %+v", changes)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
if sites[0].AlertID == 0 {
|
||||
t.Fatal("expected non-zero alert_id for existing alert reference")
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func Export(s store.Store) (*File, error) {
|
||||
dbAlerts, err := s.GetAllAlerts()
|
||||
func Export(ctx context.Context, s store.Store) (*File, error) {
|
||||
dbAlerts, err := s.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load alerts: %w", err)
|
||||
}
|
||||
|
||||
dbSites, err := s.GetSites()
|
||||
dbSites, err := s.GetSites(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sites: %w", err)
|
||||
}
|
||||
@@ -33,9 +34,9 @@ func Export(s store.Store) (*File, error) {
|
||||
})
|
||||
}
|
||||
|
||||
groups := make(map[int]models.Site)
|
||||
children := make(map[int][]models.Site)
|
||||
var topLevel []models.Site
|
||||
groups := make(map[int]models.SiteConfig)
|
||||
children := make(map[int][]models.SiteConfig)
|
||||
var topLevel []models.SiteConfig
|
||||
|
||||
for _, s := range dbSites {
|
||||
switch {
|
||||
@@ -75,7 +76,7 @@ func Export(s store.Store) (*File, error) {
|
||||
return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil
|
||||
}
|
||||
|
||||
func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor {
|
||||
func siteToMonitor(s models.SiteConfig, alertIDToName map[int]string) Monitor {
|
||||
m := Monitor{
|
||||
Name: s.Name,
|
||||
Type: s.Type,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestExportEmpty(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
f, err := Export(s)
|
||||
f, err := Export(context.Background(), s)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
@@ -18,11 +20,11 @@ func TestExportEmpty(t *testing.T) {
|
||||
|
||||
func TestExportAlertNames(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
|
||||
alerts, _ := s.GetAllAlerts()
|
||||
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"})
|
||||
s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"})
|
||||
alerts, _ := s.GetAllAlerts(context.Background())
|
||||
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"})
|
||||
|
||||
f, err := Export(s)
|
||||
f, err := Export(context.Background(), s)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
@@ -37,11 +39,11 @@ func TestExportAlertNames(t *testing.T) {
|
||||
|
||||
func TestExportGroupHierarchy(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", 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(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
|
||||
groupID, _ := s.AddSiteReturningID(context.Background(), models.SiteConfig{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(context.Background(), models.SiteConfig{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
|
||||
|
||||
f, err := Export(s)
|
||||
f, err := Export(context.Background(), s)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
@@ -70,12 +72,12 @@ func TestExportGroupHierarchy(t *testing.T) {
|
||||
|
||||
func TestExportOmitsDefaults(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
s.AddSite(models.Site{
|
||||
s.AddSite(context.Background(), models.SiteConfig{
|
||||
Name: "Web", URL: "https://example.com", Type: "http", Interval: 30,
|
||||
Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7,
|
||||
})
|
||||
|
||||
f, err := Export(s)
|
||||
f, err := Export(context.Background(), s)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
@@ -94,18 +96,18 @@ func TestExportOmitsDefaults(t *testing.T) {
|
||||
|
||||
func TestExportRoundTrip(t *testing.T) {
|
||||
s1 := newTestStore(t)
|
||||
s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
|
||||
alerts, _ := s1.GetAllAlerts()
|
||||
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(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
|
||||
s1.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"})
|
||||
alerts, _ := s1.GetAllAlerts(context.Background())
|
||||
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(context.Background(), models.SiteConfig{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
|
||||
|
||||
exported, err := Export(s1)
|
||||
exported, err := Export(context.Background(), s1)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
|
||||
s2 := newTestStore(t)
|
||||
changes, err := Apply(s2, exported, ApplyOpts{})
|
||||
changes, err := Apply(context.Background(), s2, exported, ApplyOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
@@ -120,7 +122,7 @@ func TestExportRoundTrip(t *testing.T) {
|
||||
t.Fatalf("expected 3 creates, got %d", creates)
|
||||
}
|
||||
|
||||
reexported, err := Export(s2)
|
||||
reexported, err := Export(context.Background(), s2)
|
||||
if err != nil {
|
||||
t.Fatalf("re-Export: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
type KumaBackup struct {
|
||||
@@ -80,7 +83,7 @@ func ConvertKuma(kb *KumaBackup) models.Backup {
|
||||
}
|
||||
}
|
||||
|
||||
var sites []models.Site
|
||||
var sites []models.SiteConfig
|
||||
for _, m := range kb.MonitorList {
|
||||
site := convertKumaMonitor(m, kumaToUpkeepAlert)
|
||||
sites = append(sites, site)
|
||||
@@ -132,8 +135,8 @@ func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConf
|
||||
return result
|
||||
}
|
||||
|
||||
func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
|
||||
site := models.Site{
|
||||
func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig {
|
||||
site := models.SiteConfig{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
@@ -155,10 +158,18 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
|
||||
site.DNSResolveType = m.DNSResolveType
|
||||
site.DNSServer = m.DNSResolveServer
|
||||
|
||||
site.Paused = !m.Active
|
||||
|
||||
switch m.Type {
|
||||
case "http":
|
||||
site.URL = m.URL
|
||||
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":
|
||||
if m.Hostname != "" {
|
||||
site.Hostname = m.Hostname
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTemp(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "backup.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadKumaFileMissingFile(t *testing.T) {
|
||||
_, err := LoadKumaFile(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKumaFileMalformedInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"empty file", ""},
|
||||
{"truncated JSON", `{"version": "1.23", "monitorList": [`},
|
||||
{"not JSON", "definitely not json"},
|
||||
{"wrong root type", `[1, 2, 3]`},
|
||||
{"monitorList wrong type", `{"monitorList": {"a": 1}}`},
|
||||
{"monitor field wrong type", `{"monitorList": [{"id": "not-an-int"}]}`},
|
||||
{"notificationList wrong type", `{"notificationList": "oops"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := LoadKumaFile(writeTemp(t, tc.body))
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for %s", tc.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse JSON") {
|
||||
t.Fatalf("expected wrapped parse error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKumaFileNullLists(t *testing.T) {
|
||||
kb, err := LoadKumaFile(writeTemp(t, `{"version": "1.23", "monitorList": null, "notificationList": null}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if len(backup.Sites) != 0 || len(backup.Alerts) != 0 {
|
||||
t.Fatalf("expected empty backup, got %d sites %d alerts", len(backup.Sites), len(backup.Alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaSkipsMalformedNotificationConfig(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
NotificationList: []KumaNotifEntry{
|
||||
{ID: 1, Name: "broken", Config: "{not json"},
|
||||
{ID: 2, Name: "good", Config: `{"type": "discord", "ntfyserverurl": "https://example.com/hook"}`},
|
||||
},
|
||||
MonitorList: []KumaMonitor{
|
||||
{ID: 10, Name: "site", Type: "http", URL: "https://example.com", NotificationIDs: map[string]bool{"1": true}},
|
||||
},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if len(backup.Alerts) != 1 {
|
||||
t.Fatalf("expected broken notification skipped, got %d alerts", len(backup.Alerts))
|
||||
}
|
||||
if backup.Alerts[0].Type != "discord" {
|
||||
t.Fatalf("expected discord alert, got %q", backup.Alerts[0].Type)
|
||||
}
|
||||
if backup.Sites[0].AlertID != 0 {
|
||||
t.Fatalf("site referencing skipped notification should keep AlertID 0, got %d", backup.Sites[0].AlertID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaNtfyNotification(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
NotificationList: []KumaNotifEntry{
|
||||
{ID: 3, Name: "ntfy", Config: `{
|
||||
"type": "ntfy",
|
||||
"ntfyserverurl": "https://ntfy.example.com/",
|
||||
"ntfytopic": "uptime",
|
||||
"ntfyPriority": 4,
|
||||
"ntfyAuthenticationMethod": "usernamePassword",
|
||||
"ntfyusername": "u",
|
||||
"ntfypassword": "p"
|
||||
}`},
|
||||
},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if len(backup.Alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(backup.Alerts))
|
||||
}
|
||||
a := backup.Alerts[0]
|
||||
if a.Type != "ntfy" {
|
||||
t.Fatalf("expected ntfy, got %q", a.Type)
|
||||
}
|
||||
if a.Settings["url"] != "https://ntfy.example.com" {
|
||||
t.Fatalf("expected trailing slash trimmed, got %q", a.Settings["url"])
|
||||
}
|
||||
if a.Settings["topic"] != "uptime" || a.Settings["priority"] != "4" {
|
||||
t.Fatalf("unexpected settings: %v", a.Settings)
|
||||
}
|
||||
if a.Settings["username"] != "u" || a.Settings["password"] != "p" {
|
||||
t.Fatalf("expected credentials mapped, got %v", a.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaUnknownNotificationFallsBackToWebhook(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
NotificationList: []KumaNotifEntry{
|
||||
{ID: 4, Name: "matrix", Config: `{"type": "matrix", "ntfyserverurl": "https://example.com/hook"}`},
|
||||
},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if len(backup.Alerts) != 1 || backup.Alerts[0].Type != "webhook" {
|
||||
t.Fatalf("expected webhook fallback, got %+v", backup.Alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaHTTPMonitor(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
NotificationList: []KumaNotifEntry{
|
||||
{ID: 1, Name: "hook", Config: `{"type": "slack", "ntfyserverurl": "https://example.com/hook"}`},
|
||||
},
|
||||
MonitorList: []KumaMonitor{{
|
||||
ID: 7,
|
||||
Name: "web",
|
||||
Type: "http",
|
||||
URL: "https://example.com",
|
||||
Interval: 60,
|
||||
Timeout: 30,
|
||||
MaxRetries: 2,
|
||||
Method: "GET",
|
||||
AcceptedCodes: []string{"200", "301"},
|
||||
IgnoreTLS: true,
|
||||
ExpiryNotif: true,
|
||||
Active: false,
|
||||
NotificationIDs: map[string]bool{"1": true},
|
||||
}},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if len(backup.Sites) != 1 {
|
||||
t.Fatalf("expected 1 site, got %d", len(backup.Sites))
|
||||
}
|
||||
s := backup.Sites[0]
|
||||
if s.URL != "https://example.com" || !s.CheckSSL || !s.IgnoreTLS {
|
||||
t.Fatalf("http fields not mapped: %+v", s)
|
||||
}
|
||||
if !s.Paused {
|
||||
t.Fatal("inactive monitor should import paused")
|
||||
}
|
||||
if s.AcceptedCodes != "200,301" {
|
||||
t.Fatalf("expected joined accepted codes, got %q", s.AcceptedCodes)
|
||||
}
|
||||
if s.AlertID != 1 {
|
||||
t.Fatalf("expected alert mapped, got %d", s.AlertID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaPushMonitorGetsToken(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
MonitorList: []KumaMonitor{{ID: 1, Name: "push", Type: "push", Active: true}},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
token := backup.Sites[0].Token
|
||||
if len(token) != 32 {
|
||||
t.Fatalf("expected 32-char hex token, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaNonNumericNotificationID(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
MonitorList: []KumaMonitor{{
|
||||
ID: 1,
|
||||
Name: "site",
|
||||
Type: "http",
|
||||
NotificationIDs: map[string]bool{"abc": true},
|
||||
}},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if backup.Sites[0].AlertID != 0 {
|
||||
t.Fatalf("non-numeric notification ID should not map, got %d", backup.Sites[0].AlertID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertKumaGroupAndChildren(t *testing.T) {
|
||||
kb := &KumaBackup{
|
||||
MonitorList: []KumaMonitor{
|
||||
{ID: 1, Name: "grp", Type: "group", Active: true},
|
||||
{ID: 2, Name: "ping", Type: "ping", Hostname: "10.0.0.1", Parent: 1, Active: true},
|
||||
},
|
||||
}
|
||||
backup := ConvertKuma(kb)
|
||||
if backup.Sites[0].Type != "group" {
|
||||
t.Fatalf("expected group type, got %q", backup.Sites[0].Type)
|
||||
}
|
||||
if backup.Sites[1].ParentID != 1 || backup.Sites[1].Hostname != "10.0.0.1" {
|
||||
t.Fatalf("child not mapped: %+v", backup.Sites[1])
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
func Handler(eng *monitor.Engine) http.HandlerFunc {
|
||||
@@ -19,7 +20,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
|
||||
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
|
||||
for _, s := range sites {
|
||||
val := 0
|
||||
if s.Status == "UP" {
|
||||
if s.Status == models.StatusUp {
|
||||
val = 1
|
||||
}
|
||||
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
|
||||
|
||||
@@ -2,73 +2,29 @@ package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
|
||||
)
|
||||
|
||||
type mockStore struct {
|
||||
sites []models.Site
|
||||
storetest.BaseMock
|
||||
sites []models.SiteConfig
|
||||
}
|
||||
|
||||
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) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
|
||||
return nil, nil
|
||||
func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) {
|
||||
return m.sites, 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) Close() error { return nil }
|
||||
|
||||
func TestMetricsHandler(t *testing.T) {
|
||||
ms := &mockStore{
|
||||
sites: []models.Site{
|
||||
sites: []models.SiteConfig{
|
||||
{ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30},
|
||||
{ID: 2, Name: "DNS Check", Type: "dns", Interval: 60},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Site struct {
|
||||
type SiteConfig struct {
|
||||
ID int
|
||||
Name string
|
||||
URL string
|
||||
@@ -26,15 +26,34 @@ type Site struct {
|
||||
IgnoreTLS bool
|
||||
Paused bool
|
||||
Regions string
|
||||
}
|
||||
|
||||
FailureCount int
|
||||
Status string
|
||||
StatusCode int
|
||||
Latency time.Duration
|
||||
CertExpiry time.Time
|
||||
HasSSL bool
|
||||
LastCheck time.Time
|
||||
SentSSLWarning bool
|
||||
type SiteState struct {
|
||||
FailureCount int
|
||||
Status Status
|
||||
StatusCode int
|
||||
Latency time.Duration
|
||||
CertExpiry time.Time
|
||||
HasSSL bool
|
||||
LastCheck time.Time
|
||||
SentSSLWarning bool
|
||||
LastError string
|
||||
StatusChangedAt time.Time
|
||||
LastSuccessAt time.Time
|
||||
}
|
||||
|
||||
type Site struct {
|
||||
SiteConfig
|
||||
SiteState
|
||||
}
|
||||
|
||||
type StateChange struct {
|
||||
ID int
|
||||
SiteID int
|
||||
FromStatus string
|
||||
ToStatus string
|
||||
ErrorReason string
|
||||
ChangedAt time.Time
|
||||
}
|
||||
|
||||
type AlertConfig struct {
|
||||
@@ -67,6 +86,22 @@ type ProbeNode struct {
|
||||
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 {
|
||||
ID int
|
||||
MonitorID int
|
||||
@@ -80,7 +115,7 @@ type MaintenanceWindow struct {
|
||||
}
|
||||
|
||||
type Backup struct {
|
||||
Sites []Site `json:"sites"`
|
||||
Sites []SiteConfig `json:"sites"`
|
||||
Alerts []AlertConfig `json:"alerts"`
|
||||
Users []User `json:"users"`
|
||||
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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) }
|
||||
@@ -11,10 +11,11 @@ const (
|
||||
)
|
||||
|
||||
type NodeResult struct {
|
||||
NodeID string
|
||||
IsUp bool
|
||||
LatencyNs int64
|
||||
CheckedAt time.Time
|
||||
NodeID string
|
||||
IsUp bool
|
||||
LatencyNs int64
|
||||
CheckedAt time.Time
|
||||
ErrorReason string
|
||||
}
|
||||
|
||||
func AggregateStatus(results []NodeResult, strategy AggregationStrategy) (isUp bool, avgLatencyNs int64) {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,73 +2,91 @@ package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
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 {
|
||||
SiteID int
|
||||
Status string // "UP", "DOWN", "SSL EXP"
|
||||
StatusCode int
|
||||
LatencyNs int64
|
||||
HasSSL bool
|
||||
CertExpiry time.Time
|
||||
SiteID int
|
||||
Status string // "UP", "DOWN", "SSL EXP"
|
||||
StatusCode int
|
||||
LatencyNs int64
|
||||
HasSSL bool
|
||||
CertExpiry time.Time
|
||||
ErrorReason string
|
||||
}
|
||||
|
||||
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
|
||||
private := len(allowPrivate) > 0 && allowPrivate[0]
|
||||
|
||||
if site.Type != "http" && site.Type != "dns" && !private {
|
||||
func RunCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure, allowPrivate bool) CheckResult {
|
||||
// Resolve + validate once for non-HTTP types to prevent DNS-rebind TOCTOU:
|
||||
// a second resolve in the check function could return a different (private) IP.
|
||||
// HTTP is safe — SafeDialContext resolves and validates at dial time.
|
||||
var pinnedIP net.IP
|
||||
if site.Type != "http" && site.Type != "dns" && !allowPrivate {
|
||||
host := site.Hostname
|
||||
if host == "" {
|
||||
host = site.URL
|
||||
}
|
||||
if host != "" {
|
||||
if ips, err := net.LookupIP(host); err == nil {
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "resolve failed: " + err.Error()}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "target resolves to private IP"}
|
||||
}
|
||||
}
|
||||
pinnedIP = ips[0]
|
||||
}
|
||||
}
|
||||
|
||||
switch site.Type {
|
||||
case "http":
|
||||
return runHTTPCheck(site, strict, insecure, globalInsecure)
|
||||
return runHTTPCheck(ctx, site, strict, insecure, globalInsecure)
|
||||
case "ping":
|
||||
return runPingCheck(site)
|
||||
return runPingCheck(ctx, site, pinnedIP)
|
||||
case "port":
|
||||
return runPortCheck(site)
|
||||
return runPortCheck(ctx, site, pinnedIP)
|
||||
case "dns":
|
||||
return runDNSCheck(site)
|
||||
return runDNSCheck(ctx, site, allowPrivate)
|
||||
default:
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "unsupported monitor type: " + site.Type}
|
||||
}
|
||||
}
|
||||
|
||||
func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
|
||||
func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure bool) CheckResult {
|
||||
method := site.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
|
||||
timeout := siteTimeout(site)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "invalid request: " + err.Error()}
|
||||
}
|
||||
|
||||
client := strict
|
||||
@@ -82,19 +100,28 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
||||
|
||||
result := CheckResult{
|
||||
SiteID: site.ID,
|
||||
Status: "UP",
|
||||
Status: string(models.StatusUp),
|
||||
LatencyNs: latency.Nanoseconds(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Status = "DOWN"
|
||||
result.Status = string(models.StatusDown)
|
||||
result.ErrorReason = truncateError(err.Error(), maxErrorLength)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
result.StatusCode = resp.StatusCode
|
||||
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
|
||||
result.Status = "DOWN"
|
||||
result.Status = string(models.StatusDown)
|
||||
expected := site.AcceptedCodes
|
||||
if expected == "" {
|
||||
expected = defaultAcceptedCodes
|
||||
}
|
||||
result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected)
|
||||
}
|
||||
|
||||
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||
@@ -102,14 +129,15 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
||||
cert := resp.TLS.PeerCertificates[0]
|
||||
result.CertExpiry = cert.NotAfter
|
||||
if time.Now().After(cert.NotAfter) {
|
||||
result.Status = "SSL EXP"
|
||||
result.Status = string(models.StatusSSLExp)
|
||||
result.ErrorReason = "SSL certificate expired"
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func runPingCheck(site models.Site) CheckResult {
|
||||
func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult {
|
||||
host := site.Hostname
|
||||
if host == "" {
|
||||
host = site.URL
|
||||
@@ -117,7 +145,10 @@ func runPingCheck(site models.Site) CheckResult {
|
||||
|
||||
pinger, err := probing.NewPinger(host)
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "ping setup: " + err.Error()}
|
||||
}
|
||||
if pinnedIP != nil {
|
||||
pinger.SetIPAddr(&net.IPAddr{IP: pinnedIP})
|
||||
}
|
||||
pinger.Count = 1
|
||||
pinger.Timeout = siteTimeout(site)
|
||||
@@ -127,19 +158,25 @@ func runPingCheck(site models.Site) CheckResult {
|
||||
err = pinger.Run()
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil || pinger.Statistics().PacketsRecv == 0 {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
|
||||
}
|
||||
if pinger.Statistics().PacketsRecv == 0 {
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
|
||||
}
|
||||
|
||||
stats := pinger.Statistics()
|
||||
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: stats.AvgRtt.Nanoseconds()}
|
||||
}
|
||||
|
||||
func runPortCheck(site models.Site) CheckResult {
|
||||
func runPortCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult {
|
||||
host := site.Hostname
|
||||
if host == "" {
|
||||
host = site.URL
|
||||
}
|
||||
if pinnedIP != nil {
|
||||
host = pinnedIP.String()
|
||||
}
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(site.Port))
|
||||
timeout := siteTimeout(site)
|
||||
|
||||
@@ -148,13 +185,13 @@ func runPortCheck(site models.Site) CheckResult {
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)}
|
||||
}
|
||||
_ = conn.Close()
|
||||
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
|
||||
}
|
||||
|
||||
func runDNSCheck(site models.Site) CheckResult {
|
||||
func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) CheckResult {
|
||||
host := site.Hostname
|
||||
if host == "" {
|
||||
host = site.URL
|
||||
@@ -162,11 +199,26 @@ func runDNSCheck(site models.Site) CheckResult {
|
||||
|
||||
server := site.DNSServer
|
||||
if server == "" {
|
||||
server = "1.1.1.1"
|
||||
server = defaultDNSServer
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(server); err != nil {
|
||||
server = net.JoinHostPort(server, "53")
|
||||
serverHost, serverPort, err := net.SplitHostPort(server)
|
||||
if err != nil {
|
||||
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
|
||||
switch site.DNSResolveType {
|
||||
@@ -199,24 +251,24 @@ func runDNSCheck(site models.Site) CheckResult {
|
||||
latency := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds()}
|
||||
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: "UP", LatencyNs: latency.Nanoseconds()}
|
||||
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()}
|
||||
}
|
||||
|
||||
func siteTimeout(site models.Site) time.Duration {
|
||||
func siteTimeout(site models.SiteConfig) time.Duration {
|
||||
if site.Timeout > 0 {
|
||||
return time.Duration(site.Timeout) * time.Second
|
||||
}
|
||||
return 5 * time.Second
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
func isCodeAccepted(code int, accepted string) bool {
|
||||
if accepted == "" {
|
||||
return code >= 200 && code < 300
|
||||
return code >= defaultHTTPStatusMin && code < defaultHTTPStatusMax
|
||||
}
|
||||
for _, part := range strings.Split(accepted, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
@@ -235,3 +287,10 @@ func isCodeAccepted(code int, accepted string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func truncateError(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -9,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||
@@ -18,8 +19,8 @@ func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL}
|
||||
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}
|
||||
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false)
|
||||
|
||||
if result.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", result.Status)
|
||||
@@ -38,8 +39,8 @@ func TestRunCheck_HTTP_ServerError(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL}
|
||||
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}
|
||||
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false)
|
||||
|
||||
if result.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN, got %s", result.Status)
|
||||
@@ -59,8 +60,8 @@ func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"}
|
||||
result := RunCheck(site, client, client, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"}
|
||||
result := RunCheck(context.Background(), site, client, client, false, false)
|
||||
|
||||
if result.Status != "UP" {
|
||||
t.Errorf("expected UP with accepted 200-399, got %s", result.Status)
|
||||
@@ -75,8 +76,8 @@ func TestRunCheck_HTTP_MethodRespected(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"}
|
||||
RunCheck(site, http.DefaultClient, http.DefaultClient, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"}
|
||||
RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false)
|
||||
|
||||
if receivedMethod != "HEAD" {
|
||||
t.Errorf("expected HEAD, got %s", receivedMethod)
|
||||
@@ -90,8 +91,8 @@ func TestRunCheck_HTTP_Timeout(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1}
|
||||
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Timeout: 1}
|
||||
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false)
|
||||
|
||||
if result.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN on timeout, got %s", result.Status)
|
||||
@@ -108,8 +109,8 @@ func TestRunCheck_HTTP_SSLFields(t *testing.T) {
|
||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
|
||||
}
|
||||
|
||||
site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true}
|
||||
result := RunCheck(site, http.DefaultClient, insecureClient, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true}
|
||||
result := RunCheck(context.Background(), site, http.DefaultClient, insecureClient, false, false)
|
||||
|
||||
if result.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", result.Status)
|
||||
@@ -129,11 +130,17 @@ func TestRunCheck_Port_Open(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, 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.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(site, nil, nil, false, true)
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, true)
|
||||
|
||||
if result.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", result.Status)
|
||||
@@ -148,18 +155,73 @@ func TestRunCheck_Port_Closed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, 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)
|
||||
}
|
||||
ln.Close()
|
||||
|
||||
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
|
||||
result := RunCheck(site, nil, nil, false, true)
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, true)
|
||||
|
||||
if result.Status != "DOWN" {
|
||||
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) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -167,11 +229,17 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, 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.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(site, nil, nil, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, false)
|
||||
|
||||
if result.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN when private targets blocked, got %s", result.Status)
|
||||
@@ -179,8 +247,8 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunCheck_UnknownType(t *testing.T) {
|
||||
site := models.Site{ID: 1, Type: "invalid"}
|
||||
result := RunCheck(site, nil, nil, false)
|
||||
site := models.SiteConfig{ID: 1, Type: "invalid"}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, false)
|
||||
|
||||
if result.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN for unknown type, got %s", result.Status)
|
||||
@@ -213,10 +281,10 @@ func TestIsCodeAccepted(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSiteTimeout(t *testing.T) {
|
||||
if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second {
|
||||
if got := siteTimeout(models.SiteConfig{Timeout: 0}); got != 5*time.Second {
|
||||
t.Errorf("expected 5s default, got %v", got)
|
||||
}
|
||||
if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second {
|
||||
if got := siteTimeout(models.SiteConfig{Timeout: 10}); got != 10*time.Second {
|
||||
t.Errorf("expected 10s, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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" }
|
||||
@@ -1,6 +1,9 @@
|
||||
package monitor
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxHistoryLen = 60
|
||||
|
||||
@@ -11,8 +14,8 @@ type SiteHistory struct {
|
||||
UpChecks int
|
||||
}
|
||||
|
||||
func (e *Engine) InitHistory() {
|
||||
all, err := e.db.LoadAllHistory(maxHistoryLen)
|
||||
func (e *Engine) InitHistory(ctx context.Context) {
|
||||
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
|
||||
if err != nil {
|
||||
e.AddLog("Failed to load check history: " + err.Error())
|
||||
return
|
||||
@@ -61,7 +64,7 @@ func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) {
|
||||
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
|
||||
}
|
||||
|
||||
go func() { _ = e.db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }()
|
||||
e.enqueueWrite(writeCheck{siteID: siteID, latencyNs: latency.Nanoseconds(), isUp: isUp})
|
||||
}
|
||||
|
||||
func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,26 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLogEntries = 100
|
||||
pollInterval = 5 * time.Second
|
||||
pushGracePeriod = 5 * time.Second
|
||||
minCheckInterval = 5
|
||||
maxLogEntries = 100
|
||||
pollInterval = 5 * time.Second
|
||||
minCheckInterval = 5
|
||||
minPushGrace = 60 * time.Second
|
||||
maintPruneInterval = 15 * time.Minute
|
||||
defaultMaintRetention = 7 * 24 * time.Hour
|
||||
dbWriteBuffer = 4096
|
||||
alertSendTimeout = 30 * time.Second
|
||||
dbPruneInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
@@ -28,7 +31,7 @@ type Engine struct {
|
||||
liveState map[int]models.Site
|
||||
|
||||
logMu sync.RWMutex
|
||||
logStore []string
|
||||
logStore []models.LogEntry
|
||||
|
||||
activeMu sync.RWMutex
|
||||
isActive bool
|
||||
@@ -42,11 +45,28 @@ type Engine struct {
|
||||
probeResults map[int]map[string]NodeResult
|
||||
aggStrategy AggregationStrategy
|
||||
|
||||
alertHealthMu sync.RWMutex
|
||||
alertHealth map[int]AlertHealth
|
||||
|
||||
recheckMu sync.RWMutex
|
||||
recheck map[int]chan struct{}
|
||||
|
||||
maintCacheMu sync.RWMutex
|
||||
maintCache map[int]bool
|
||||
|
||||
db store.Store
|
||||
insecureSkipVerify bool
|
||||
allowPrivateTargets bool
|
||||
maintRetention time.Duration
|
||||
strictClient *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 {
|
||||
@@ -63,10 +83,15 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
||||
liveState: make(map[int]models.Site),
|
||||
histories: make(map[int]*SiteHistory),
|
||||
tokenIndex: make(map[string]int),
|
||||
recheck: make(map[int]chan struct{}),
|
||||
probeResults: make(map[int]map[string]NodeResult),
|
||||
alertHealth: make(map[int]AlertHealth),
|
||||
aggStrategy: AggAnyDown,
|
||||
isActive: true,
|
||||
allowPrivateTargets: allowPrivateTargets,
|
||||
maintRetention: defaultMaintRetention,
|
||||
dbWrites: make(chan dbWrite, dbWriteBuffer),
|
||||
ctx: context.Background(),
|
||||
db: s,
|
||||
strictClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -83,10 +108,18 @@ 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) {
|
||||
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]`)
|
||||
|
||||
func sanitizeLog(s string) string {
|
||||
@@ -96,127 +129,153 @@ func sanitizeLog(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func (e *Engine) AddLog(msg string) {
|
||||
func fmtDurationShort(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 {
|
||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
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
|
||||
// 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()
|
||||
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...)
|
||||
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
|
||||
if len(e.logStore) > maxLogEntries {
|
||||
e.logStore = e.logStore[:maxLogEntries]
|
||||
}
|
||||
go func() { _ = e.db.SaveLog(entry) }()
|
||||
e.logMu.Unlock()
|
||||
return entry
|
||||
}
|
||||
|
||||
func (e *Engine) InitLogs() {
|
||||
logs, err := e.db.LoadLogs(100)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
return
|
||||
}
|
||||
e.logMu.Lock()
|
||||
defer e.logMu.Unlock()
|
||||
e.logStore = logs
|
||||
func (e *Engine) AddLog(msg string) {
|
||||
entry := e.appendLog(msg)
|
||||
e.enqueueWrite(writeLog{message: entry.Message})
|
||||
}
|
||||
|
||||
func (e *Engine) GetLogs() []string {
|
||||
e.logMu.RLock()
|
||||
defer e.logMu.RUnlock()
|
||||
logs := make([]string, len(e.logStore))
|
||||
copy(logs, e.logStore)
|
||||
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))
|
||||
// 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()))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) IsActive() bool {
|
||||
e.activeMu.RLock()
|
||||
defer e.activeMu.RUnlock()
|
||||
return e.isActive
|
||||
}
|
||||
// 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()
|
||||
|
||||
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
|
||||
}
|
||||
pruneTicker := time.NewTicker(dbPruneInterval)
|
||||
defer pruneTicker.Stop()
|
||||
e.prune(ctx)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
site.LastCheck = time.Now()
|
||||
wasDown := site.Status == "DOWN"
|
||||
site.Status = "UP"
|
||||
site.FailureCount = 0
|
||||
site.Latency = 0
|
||||
e.liveState[targetID] = site
|
||||
|
||||
if wasDown {
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name))
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name))
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
e.logMu.Lock()
|
||||
defer e.logMu.Unlock()
|
||||
e.logStore = entries
|
||||
}
|
||||
|
||||
func (e *Engine) GetLogs() []models.LogEntry {
|
||||
e.logMu.RLock()
|
||||
defer e.logMu.RUnlock()
|
||||
logs := make([]models.LogEntry, len(e.logStore))
|
||||
copy(logs, e.logStore)
|
||||
return logs
|
||||
}
|
||||
|
||||
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() {
|
||||
defer e.checkerWG.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -224,7 +283,7 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
default:
|
||||
}
|
||||
|
||||
sites, err := e.db.GetSites()
|
||||
configs, err := e.db.GetSites(ctx)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
||||
select {
|
||||
@@ -234,33 +293,55 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, s := range sites {
|
||||
dbIDs := make(map[int]bool, len(configs))
|
||||
for _, cfg := range configs {
|
||||
dbIDs[cfg.ID] = true
|
||||
e.mu.RLock()
|
||||
_, exists := e.liveState[s.ID]
|
||||
existing, exists := e.liveState[cfg.ID]
|
||||
e.mu.RUnlock()
|
||||
if !exists {
|
||||
e.mu.Lock()
|
||||
s.Status = "PENDING"
|
||||
if s.Type == "push" {
|
||||
s.LastCheck = time.Now()
|
||||
}
|
||||
if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
|
||||
site := models.Site{SiteConfig: cfg, SiteState: models.SiteState{Status: models.StatusPending}}
|
||||
if h, ok := e.GetHistory(cfg.ID); ok && len(h.Statuses) > 0 {
|
||||
if h.Statuses[len(h.Statuses)-1] {
|
||||
s.Status = "UP"
|
||||
site.Status = models.StatusUp
|
||||
} else {
|
||||
s.Status = "DOWN"
|
||||
site.Status = models.StatusDown
|
||||
}
|
||||
if len(h.Latencies) > 0 {
|
||||
s.Latency = h.Latencies[len(h.Latencies)-1]
|
||||
site.Latency = h.Latencies[len(h.Latencies)-1]
|
||||
}
|
||||
}
|
||||
e.liveState[s.ID] = s
|
||||
e.addToTokenIndex(s)
|
||||
e.liveState[cfg.ID] = site
|
||||
e.addToTokenIndex(site)
|
||||
e.mu.Unlock()
|
||||
go e.monitorRoutine(ctx, s.ID)
|
||||
e.checkerWG.Add(1)
|
||||
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 {
|
||||
case <-time.After(pollInterval):
|
||||
case <-ctx.Done():
|
||||
@@ -268,336 +349,10 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
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
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) checkPush(site models.Site) {
|
||||
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(pushGracePeriod)
|
||||
if time.Now().After(deadline) {
|
||||
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||
} else if site.Status != "UP" {
|
||||
e.handleStatusChange(site, "UP", 200, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) {
|
||||
if !e.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
newState := site
|
||||
newState.StatusCode = code
|
||||
|
||||
if site.Status == "UP" && rawStatus != "UP" {
|
||||
newState.FailureCount++
|
||||
if newState.FailureCount > site.MaxRetries {
|
||||
newState.Status = rawStatus
|
||||
newState.FailureCount = site.MaxRetries + 1
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||
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 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" {
|
||||
if !inMaint {
|
||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", 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() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := provider.Send(ctx, title, message); err != nil {
|
||||
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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(),
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
e.checkerWG.Add(1)
|
||||
go func() {
|
||||
defer e.checkerWG.Done()
|
||||
e.maintenancePruner(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
var privateRanges []*net.IPNet
|
||||
|
||||
func init() {
|
||||
cidrs := []string{
|
||||
"0.0.0.0/8",
|
||||
"127.0.0.0/8",
|
||||
"::1/128",
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"169.254.0.0/16",
|
||||
@@ -27,6 +31,9 @@ func init() {
|
||||
}
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
for _, network := range privateRanges {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
@@ -55,7 +62,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||
for _, ip := range ips {
|
||||
target := net.JoinHostPort(ip.IP.String(), port)
|
||||
conn, err := dialer.DialContext(ctx, network, target)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
@@ -3,10 +3,22 @@ package server
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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 {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
@@ -17,18 +29,26 @@ type RateLimiter struct {
|
||||
visitors map[string]*visitor
|
||||
rate float64
|
||||
burst float64
|
||||
trusted []*net.IPNet
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewRateLimiter(requestsPerMinute int) *RateLimiter {
|
||||
func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
rate: float64(requestsPerMinute) / 60.0,
|
||||
burst: float64(requestsPerMinute),
|
||||
trusted: trusted,
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Stop() {
|
||||
close(rl.stop)
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
@@ -37,6 +57,9 @@ func (rl *RateLimiter) Allow(ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
if !exists {
|
||||
if len(rl.visitors) >= maxVisitors {
|
||||
rl.evictOldest()
|
||||
}
|
||||
rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now}
|
||||
return true
|
||||
}
|
||||
@@ -55,34 +78,90 @@ func (rl *RateLimiter) Allow(ip string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
rl.mu.Lock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
for ip, v := range rl.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
if oldestKey != "" {
|
||||
delete(rl.visitors, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
return fwd
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(visitorCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rl.mu.Lock()
|
||||
cutoff := time.Now().Add(-visitorIdleCutoff)
|
||||
for ip, v := range rl.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(rl.visitors, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
case <-rl.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP determines the rate-limit key for a request. X-Forwarded-For is only
|
||||
// honored when the immediate peer (RemoteAddr) is a configured trusted proxy;
|
||||
// otherwise the header is attacker-controlled and ignored, so a spoofed XFF
|
||||
// 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)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
host = 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
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !limiter.Allow(clientIP(r)) {
|
||||
if !limiter.Allow(clientIP(r, limiter.trusted)) {
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,21 +5,419 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
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 {
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||
}
|
||||
@@ -32,21 +430,78 @@ func extractBearerToken(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sensitiveKeys = map[string]bool{
|
||||
"pass": true, "password": true, "token": true,
|
||||
"routing_key": true, "user": true, "username": true,
|
||||
// statusSite is the public DTO for /status/json.
|
||||
type statusSite struct {
|
||||
Name string
|
||||
Type string
|
||||
URL string
|
||||
Status string
|
||||
Paused bool
|
||||
LastCheck time.Time
|
||||
Latency time.Duration
|
||||
}
|
||||
|
||||
func redactSettings(settings map[string]string) map[string]string {
|
||||
redacted := make(map[string]string, len(settings))
|
||||
for k, v := range settings {
|
||||
if sensitiveKeys[k] && v != "" {
|
||||
redacted[k] = "***REDACTED***"
|
||||
} else {
|
||||
redacted[k] = v
|
||||
// --- Middleware ---
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
|
||||
var statusTpl = template.Must(template.New("status").Parse(`
|
||||
@@ -67,6 +522,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
||||
.UP { background: #9ece6a; color: #1a1b26; }
|
||||
.DOWN { background: #f7768e; color: #1a1b26; }
|
||||
.PENDING { background: #e0af68; color: #1a1b26; }
|
||||
.LATE { background: #e0af68; color: #1a1b26; }
|
||||
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
||||
.PAUSED { background: #565f89; color: #c0caf5; }
|
||||
.MAINT { background: #bb9af7; color: #1a1b26; }
|
||||
@@ -179,394 +635,3 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
||||
</script>
|
||||
</body>
|
||||
</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"`
|
||||
} `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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,26 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
|
||||
)
|
||||
|
||||
// --- Mock Store ---
|
||||
|
||||
type mockStore struct {
|
||||
storetest.BaseMock
|
||||
mu sync.Mutex
|
||||
sites []models.Site
|
||||
sites []models.SiteConfig
|
||||
alerts []models.AlertConfig
|
||||
nodes map[string]models.ProbeNode
|
||||
importedData *models.Backup
|
||||
@@ -31,67 +35,26 @@ func newMockStore() *mockStore {
|
||||
}
|
||||
}
|
||||
|
||||
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 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) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil }
|
||||
func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
|
||||
return m.alerts, 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) Close() error { return nil }
|
||||
|
||||
func (m *mockStore) ExportData() (models.Backup, error) {
|
||||
func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) {
|
||||
return models.Backup{
|
||||
Sites: m.sites,
|
||||
Alerts: m.alerts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockStore) ImportData(data models.Backup) error {
|
||||
func (m *mockStore) ImportData(_ context.Context, data models.Backup) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.importedData = &data
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) RegisterNode(node models.ProbeNode) error {
|
||||
func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.registeredNodes = append(m.registeredNodes, node)
|
||||
@@ -99,7 +62,7 @@ func (m *mockStore) RegisterNode(node models.ProbeNode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockStore) GetNode(id string) (models.ProbeNode, error) {
|
||||
func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if n, ok := m.nodes[id]; ok {
|
||||
@@ -108,14 +71,18 @@ func (m *mockStore) GetNode(id string) (models.ProbeNode, error) {
|
||||
return models.ProbeNode{}, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||
func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) {
|
||||
return m.maintWindows, nil
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func freePort() int {
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("freePort: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
ln.Close()
|
||||
return port
|
||||
@@ -132,7 +99,7 @@ func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServ
|
||||
t.Helper()
|
||||
ms := newMockStore()
|
||||
eng := monitor.NewEngine(ms)
|
||||
port := freePort()
|
||||
port := freePort(t)
|
||||
|
||||
srv := Start(ServerConfig{
|
||||
Port: port,
|
||||
@@ -178,7 +145,7 @@ func authReq(method, url, secret string, body []byte) (*http.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
if secret != "" {
|
||||
req.Header.Set("X-Upkeep-Secret", secret)
|
||||
req.Header.Set("X-Uptop-Secret", secret)
|
||||
}
|
||||
return http.DefaultClient.Do(req)
|
||||
}
|
||||
@@ -256,8 +223,8 @@ func TestHealth_WrongSecret(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +256,7 @@ func TestExport_Unauthorized_WrongKey(t *testing.T) {
|
||||
|
||||
func TestExport_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}}
|
||||
ts.store.sites = []models.SiteConfig{{ID: 1, Name: "example", URL: "http://example.com"}}
|
||||
|
||||
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
|
||||
if err != nil {
|
||||
@@ -322,7 +289,10 @@ func TestImport_MethodNotAllowed(t *testing.T) {
|
||||
|
||||
func TestImport_Unauthorized(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(models.Backup{})
|
||||
body, err := json.Marshal(models.Backup{})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -336,9 +306,12 @@ func TestImport_Unauthorized(t *testing.T) {
|
||||
func TestImport_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
backup := models.Backup{
|
||||
Sites: []models.Site{{Name: "imported", URL: "http://example.com"}},
|
||||
Sites: []models.SiteConfig{{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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -370,9 +343,12 @@ func TestImport_InvalidJSON(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -393,7 +369,10 @@ func TestProbeRegister_Success(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_MissingID(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{"name": "test"})
|
||||
body, err := 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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -406,7 +385,10 @@ func TestProbeRegister_MissingID(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_Unauthorized(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{"id": "node-1"})
|
||||
body, err := 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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -421,12 +403,15 @@ func TestProbeRegister_Unauthorized(t *testing.T) {
|
||||
|
||||
func TestProbeResults_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"node_id": "node-1",
|
||||
"results": []map[string]any{
|
||||
{"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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -439,9 +424,12 @@ func TestProbeResults_Success(t *testing.T) {
|
||||
|
||||
func TestProbeResults_MissingNodeID(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body, err := json.Marshal(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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -466,15 +454,32 @@ func TestStatusPage_Enabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusJSON_TokensStripped(t *testing.T) {
|
||||
func TestStatusJSON_PublicDTOOnly(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", true)
|
||||
|
||||
// Inject a site with a token into engine state
|
||||
ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"})
|
||||
// Need to inject directly since UpdateSiteConfig only updates existing
|
||||
func() {
|
||||
ts.engine.RecordHeartbeat("unused") // just to exercise, won't match
|
||||
}()
|
||||
// Seed a push monitor (no network IO) through the store and start the
|
||||
// engine so its poll loop loads it into live state — the path real sites
|
||||
// take. The old version of this test injected via UpdateSiteConfig, which
|
||||
// no-ops for unknown IDs, so it asserted over zero sites and passed
|
||||
// against a server that leaked tokens.
|
||||
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")
|
||||
if err != nil {
|
||||
@@ -484,11 +489,23 @@ func TestStatusJSON_TokensStripped(t *testing.T) {
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
var state map[string]models.Site
|
||||
json.NewDecoder(resp.Body).Decode(&state)
|
||||
|
||||
// Decode raw so absent struct fields can't mask leaked JSON keys.
|
||||
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 {
|
||||
if site.Token != "" {
|
||||
t.Error("expected token stripped from status JSON response")
|
||||
if site["Name"] != "test" {
|
||||
t.Errorf("expected Name to be public, got %v", site["Name"])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,3 +568,114 @@ func TestProbeAssignments_Unauthorized(t *testing.T) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,14 @@ func TestEncryptorUniqueCiphertexts(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, _ := enc.Encrypt("same")
|
||||
b, _ := enc.Encrypt("same")
|
||||
a, err := enc.Encrypt("same")
|
||||
if err != nil {
|
||||
t.Fatalf("first encrypt: %v", err)
|
||||
}
|
||||
b, err := enc.Encrypt("same")
|
||||
if err != nil {
|
||||
t.Fatalf("second encrypt: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("two encryptions of same plaintext should produce different ciphertexts")
|
||||
}
|
||||
|
||||
@@ -5,15 +5,23 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version int
|
||||
SQL string
|
||||
}
|
||||
|
||||
type Dialect interface {
|
||||
DriverName() string
|
||||
CreateTablesSQL() []string
|
||||
MigrationsSQL() []string
|
||||
Migrations() []Migration
|
||||
BaselineVersion() int
|
||||
BoolFalse() string
|
||||
ResetSequenceOnEmpty(db *sql.DB, table string)
|
||||
ImportWipe(tx *sql.Tx)
|
||||
ImportWipeUsers(tx *sql.Tx)
|
||||
ImportResetSequences(tx *sql.Tx)
|
||||
UpsertNodeSQL() string
|
||||
UpsertAlertHealthSQL() string
|
||||
}
|
||||
|
||||
func rewritePlaceholders(query string, dollarStyle bool) string {
|
||||
|
||||
@@ -2,7 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
@@ -13,8 +13,9 @@ func NewPostgresStore(connStr string) (*SQLStore, error) {
|
||||
return NewSQLStore("postgres", connStr, &PostgresDialect{})
|
||||
}
|
||||
|
||||
func (d *PostgresDialect) DriverName() string { return "postgres" }
|
||||
func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
|
||||
func (d *PostgresDialect) DriverName() string { return "postgres" }
|
||||
func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
|
||||
func (d *PostgresDialect) BaselineVersion() int { return 21 }
|
||||
|
||||
func (d *PostgresDialect) CreateTablesSQL() []string {
|
||||
return []string{
|
||||
@@ -32,7 +33,8 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
||||
method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
|
||||
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
|
||||
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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -42,20 +44,21 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
||||
`CREATE TABLE IF NOT EXISTS check_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL, latency_ns BIGINT,
|
||||
is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW()
|
||||
is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
node_id TEXT DEFAULT ''
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
region TEXT DEFAULT '',
|
||||
last_seen TIMESTAMP DEFAULT NOW(),
|
||||
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
version TEXT DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -63,33 +66,58 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
type TEXT DEFAULT 'maintenance',
|
||||
start_time TIMESTAMP NOT NULL,
|
||||
end_time TIMESTAMP,
|
||||
start_time TIMESTAMPTZ NOT NULL,
|
||||
end_time TIMESTAMPTZ,
|
||||
created_by TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS state_changes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
site_id INTEGER NOT NULL,
|
||||
from_status TEXT NOT NULL,
|
||||
to_status TEXT NOT NULL,
|
||||
error_reason TEXT DEFAULT '',
|
||||
changed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`,
|
||||
`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) MigrationsSQL() []string {
|
||||
return []string{
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE",
|
||||
"ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''",
|
||||
func (d *PostgresDialect) Migrations() []Migration {
|
||||
return []Migration{
|
||||
{1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"},
|
||||
{2, "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"},
|
||||
{4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"},
|
||||
{5, "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"},
|
||||
{7, "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 ''"},
|
||||
{9, "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"},
|
||||
{11, "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 ''"},
|
||||
{13, "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'"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,34 +125,50 @@ 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"
|
||||
}
|
||||
|
||||
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) ImportWipe(tx *sql.Tx) {
|
||||
if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "sites", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil {
|
||||
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)
|
||||
slog.Debug("import wipe failed", "table", "alerts", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", 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) {
|
||||
if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil {
|
||||
log.Printf("sequence reset error: %v", err)
|
||||
slog.Debug("sequence reset failed", "table", "sites", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil {
|
||||
log.Printf("sequence reset error: %v", err)
|
||||
slog.Debug("sequence reset failed", "table", "alerts", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil {
|
||||
log.Printf("sequence reset error: %v", err)
|
||||
slog.Debug("sequence reset failed", "table", "users", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil {
|
||||
log.Printf("sequence reset error: %v", err)
|
||||
slog.Debug("sequence reset failed", "table", "maintenance_windows", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,43 @@ package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLiteDialect struct{}
|
||||
|
||||
func NewSQLiteStore(path string) (*SQLStore, error) {
|
||||
s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
|
||||
// Apply pragmas via the DSN so every pooled connection gets them — a
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
log.Printf("WAL mode failed: %v", err)
|
||||
if path != ":memory:" {
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
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
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
|
||||
func (d *SQLiteDialect) BoolFalse() string { return "0" }
|
||||
func (d *SQLiteDialect) DriverName() string { return "sqlite" }
|
||||
func (d *SQLiteDialect) BoolFalse() string { return "0" }
|
||||
func (d *SQLiteDialect) BaselineVersion() int { return 13 }
|
||||
|
||||
func (d *SQLiteDialect) CreateTablesSQL() []string {
|
||||
return []string{
|
||||
@@ -39,7 +56,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
||||
method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
|
||||
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -49,7 +67,8 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
||||
`CREATE TABLE IF NOT EXISTS check_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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 TABLE IF NOT EXISTS nodes (
|
||||
@@ -79,24 +98,41 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS state_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
from_status TEXT NOT NULL,
|
||||
to_status TEXT NOT NULL,
|
||||
error_reason TEXT DEFAULT '',
|
||||
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`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) MigrationsSQL() []string {
|
||||
return []string{
|
||||
"ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'",
|
||||
"ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'",
|
||||
"ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0",
|
||||
"ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0",
|
||||
"ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''",
|
||||
"ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''",
|
||||
func (d *SQLiteDialect) Migrations() []Migration {
|
||||
return []Migration{
|
||||
{1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"},
|
||||
{2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"},
|
||||
{3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"},
|
||||
{4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"},
|
||||
{5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"},
|
||||
{6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"},
|
||||
{7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"},
|
||||
{8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"},
|
||||
{9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"},
|
||||
{10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"},
|
||||
{11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"},
|
||||
{12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"},
|
||||
{13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,40 +140,56 @@ func (d *SQLiteDialect) UpsertNodeSQL() string {
|
||||
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) {
|
||||
var count int
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
|
||||
if count == 0 {
|
||||
if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil {
|
||||
log.Printf("sequence cleanup error: %v", err)
|
||||
slog.Debug("sequence cleanup failed", "table", table, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
|
||||
if _, err := tx.Exec("DELETE FROM sites"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "sites", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "sqlite_sequence(sites)", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "alerts", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM users"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "sqlite_sequence(alerts)", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil {
|
||||
log.Printf("import wipe error: %v", err)
|
||||
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 {
|
||||
slog.Debug("import wipe failed", "table", "users", "err", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil {
|
||||
slog.Debug("import wipe failed", "table", "sqlite_sequence(users)", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCheckHistory = 1000
|
||||
checkHistoryPruneAt = 1100
|
||||
maxMaintenanceExport = 1000
|
||||
maxRequestBody = 1 << 20
|
||||
maxCheckHistory = 1000
|
||||
maxLogRows = 200
|
||||
maxStateChangesPerSite = 5000
|
||||
maxMaintenanceExport = 1000
|
||||
maxOpenConns = 25
|
||||
maxIdleConns = 5
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
tokenByteLen = 16
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
@@ -31,9 +35,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxLifetime(connMaxLifetime)
|
||||
_, isDollar := dialect.(*PostgresDialect)
|
||||
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
||||
}
|
||||
@@ -61,7 +65,7 @@ func (s *SQLStore) q(query string) string {
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
b := make([]byte, tokenByteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
||||
}
|
||||
@@ -72,38 +76,59 @@ func (s *SQLStore) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *SQLStore) Init() error {
|
||||
func (s *SQLStore) Init(ctx context.Context) error {
|
||||
for _, stmt := range s.dialect.CreateTablesSQL() {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, m := range s.dialect.MigrationsSQL() {
|
||||
if _, err := s.db.Exec(m); err != nil {
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`); 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(¤t) //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
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, m.SQL); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetSites() ([]models.Site, error) {
|
||||
func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) {
|
||||
bf := s.dialect.BoolFalse()
|
||||
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",
|
||||
bf, bf,
|
||||
)
|
||||
rows, err := s.db.Query(query)
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var sites []models.Site
|
||||
var sites []models.SiteConfig
|
||||
for rows.Next() {
|
||||
var st models.Site
|
||||
var st models.SiteConfig
|
||||
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.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
|
||||
@@ -115,7 +140,7 @@ func (s *SQLStore) GetSites() ([]models.Site, error) {
|
||||
return sites, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddSite(site models.Site) error {
|
||||
func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error {
|
||||
token := ""
|
||||
if site.Type == "push" {
|
||||
var err error
|
||||
@@ -124,15 +149,17 @@ func (s *SQLStore) AddSite(site models.Site) error {
|
||||
return fmt.Errorf("generate push token: %w", err)
|
||||
}
|
||||
}
|
||||
_, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
_, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateSite(site models.Site) error {
|
||||
func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error {
|
||||
var existingToken string
|
||||
_ = s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck
|
||||
if err := s.db.QueryRowContext(ctx, s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken); err != nil && err != sql.ErrNoRows {
|
||||
return fmt.Errorf("read existing token: %w", err)
|
||||
}
|
||||
if site.Type == "push" && existingToken == "" {
|
||||
var err error
|
||||
existingToken, err = generateToken()
|
||||
@@ -140,75 +167,57 @@ func (s *SQLStore) UpdateSite(site models.Site) error {
|
||||
return fmt.Errorf("generate push token: %w", err)
|
||||
}
|
||||
}
|
||||
_, 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=?"),
|
||||
_, 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=?"),
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateSitePaused(id int, paused bool) error {
|
||||
_, err := s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id)
|
||||
func (s *SQLStore) UpdateSitePaused(ctx context.Context, id int, paused bool) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteSite(id int) error {
|
||||
_, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id)
|
||||
func (s *SQLStore) DeleteSite(ctx context.Context, id int) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
|
||||
func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) {
|
||||
bf := s.dialect.BoolFalse()
|
||||
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",
|
||||
bf, bf, s.q("?"),
|
||||
)
|
||||
var st models.Site
|
||||
err := s.db.QueryRow(query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
|
||||
var st models.SiteConfig
|
||||
err := s.db.QueryRowContext(ctx, 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.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
|
||||
&st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions)
|
||||
return st, err
|
||||
}
|
||||
|
||||
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) {
|
||||
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) {
|
||||
token := ""
|
||||
if site.Type == "push" {
|
||||
var err error
|
||||
@@ -219,12 +228,12 @@ func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
|
||||
}
|
||||
if s.dollar {
|
||||
var id int
|
||||
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"),
|
||||
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"),
|
||||
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)
|
||||
return id, err
|
||||
}
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -234,89 +243,8 @@ func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddAlertReturningID(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.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")
|
||||
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -332,56 +260,35 @@ func (s *SQLStore) GetAllUsers() ([]models.User, error) {
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddUser(username, publicKey, role string) error {
|
||||
_, err := s.db.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role)
|
||||
func (s *SQLStore) AddUser(ctx context.Context, username, publicKey, role string) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateUser(id int, username, publicKey, role string) error {
|
||||
_, err := s.db.Exec(s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id)
|
||||
func (s *SQLStore) UpdateUser(ctx context.Context, 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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteUser(id int) error {
|
||||
_, err := s.db.Exec(s.q("DELETE FROM users WHERE id=?"), id)
|
||||
func (s *SQLStore) DeleteUser(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM users WHERE id=?"), id)
|
||||
return 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)
|
||||
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error {
|
||||
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetNode(id string) (models.ProbeNode, error) {
|
||||
func (s *SQLStore) GetNode(ctx context.Context, id string) (models.ProbeNode, error) {
|
||||
var n models.ProbeNode
|
||||
err := s.db.QueryRow(s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id).
|
||||
err := s.db.QueryRowContext(ctx, 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)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name")
|
||||
func (s *SQLStore) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -397,196 +304,127 @@ func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) {
|
||||
return nodes, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateNodeLastSeen(id string) error {
|
||||
_, err := s.db.Exec(s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||
func (s *SQLStore) UpdateNodeLastSeen(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteNode(id string) error {
|
||||
_, err := s.db.Exec(s.q("DELETE FROM nodes WHERE id = ?"), id)
|
||||
func (s *SQLStore) DeleteNode(ctx context.Context, id string) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM nodes WHERE id = ?"), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) SaveLog(message string) error {
|
||||
_, 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)
|
||||
func (s *SQLStore) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, 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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var logs []string
|
||||
out := make(map[int]models.AlertHealthRecord)
|
||||
for rows.Next() {
|
||||
var msg string
|
||||
if err := rows.Scan(&msg); err != nil {
|
||||
return logs, err
|
||||
var r models.AlertHealthRecord
|
||||
var lastSend sql.NullTime
|
||||
if err := rows.Scan(&r.AlertID, &lastSend, &r.LastSendOK, &r.LastError, &r.SendCount, &r.FailCount); err != nil {
|
||||
return out, err
|
||||
}
|
||||
logs = append(logs, msg)
|
||||
if lastSend.Valid {
|
||||
r.LastSendAt = lastSend.Time
|
||||
}
|
||||
out[r.AlertID] = r
|
||||
}
|
||||
return logs, rows.Err()
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) {
|
||||
result := make(map[int][]models.CheckRecord)
|
||||
rows, err := s.db.Query(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
|
||||
func (s *SQLStore) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error {
|
||||
var lastSend interface{}
|
||||
if !h.LastSendAt.IsZero() {
|
||||
lastSend = h.LastSendAt
|
||||
}
|
||||
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()
|
||||
_, err := s.db.ExecContext(ctx, s.dialect.UpsertAlertHealthSQL(),
|
||||
h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
// SaveLog inserts a single log row. Retention is handled by PruneLogs on a
|
||||
// timer, not per-insert.
|
||||
func (s *SQLStore) SaveLog(ctx context.Context, message string) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO logs (message) VALUES (?)"), message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetActiveMaintenanceWindows() ([]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 WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||
// PruneLogs trims the logs table to the newest maxLogRows rows. The id DESC
|
||||
// tiebreak keeps ordering deterministic when rows share a created_at second.
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var windows []models.MaintenanceWindow
|
||||
var entries []models.LogEntry
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return windows, err
|
||||
var e models.LogEntry
|
||||
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
|
||||
return entries, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
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
|
||||
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
|
||||
if len(e.Message) > 3 && e.Message[0] == '[' {
|
||||
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
|
||||
e.Message = strings.TrimSpace(e.Message[idx+1:])
|
||||
}
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
return entries, 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) {
|
||||
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
|
||||
err := s.db.QueryRowContext(ctx, s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) SetPreference(key, value string) error {
|
||||
func (s *SQLStore) SetPreference(ctx context.Context, key, value string) error {
|
||||
if s.dollar {
|
||||
_, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
|
||||
_, err := s.db.ExecContext(ctx, "INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) ExportData() (models.Backup, error) {
|
||||
sites, err := s.GetSites()
|
||||
func (s *SQLStore) ExportData(ctx context.Context) (models.Backup, error) {
|
||||
sites, err := s.GetSites(ctx)
|
||||
if err != nil {
|
||||
return models.Backup{}, err
|
||||
}
|
||||
alerts, err := s.GetAllAlerts()
|
||||
alerts, err := s.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
return models.Backup{}, err
|
||||
}
|
||||
users, err := s.GetAllUsers()
|
||||
users, err := s.GetAllUsers(ctx)
|
||||
if err != nil {
|
||||
return models.Backup{}, err
|
||||
}
|
||||
windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
|
||||
windows, err := s.GetAllMaintenanceWindows(ctx, maxMaintenanceExport)
|
||||
if err != nil {
|
||||
return models.Backup{}, err
|
||||
}
|
||||
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) ImportData(data models.Backup) error {
|
||||
tx, err := s.db.Begin()
|
||||
func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -594,22 +432,29 @@ func (s *SQLStore) ImportData(data models.Backup) error {
|
||||
|
||||
s.dialect.ImportWipe(tx)
|
||||
|
||||
for _, u := range data.Users {
|
||||
if _, err := tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil {
|
||||
return err
|
||||
// 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 {
|
||||
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, a := range data.Alerts {
|
||||
jsonBytes, err := json.Marshal(a.Settings)
|
||||
// Encrypt on import exactly as AddAlert/UpdateAlert do, so a restore
|
||||
// honors UPTOP_ENCRYPTION_KEY instead of writing secrets in plaintext.
|
||||
settingsStr, err := s.marshalSettings(a.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, settingsStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, st := range data.Sites {
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
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 {
|
||||
return err
|
||||
@@ -617,7 +462,7 @@ func (s *SQLStore) ImportData(data models.Backup) error {
|
||||
}
|
||||
|
||||
for _, mw := range data.MaintenanceWindows {
|
||||
if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||
if _, err := tx.ExecContext(ctx, 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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *SQLStore {
|
||||
@@ -11,7 +14,7 @@ func newTestStore(t *testing.T) *SQLStore {
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
if err := s.Init(); err != nil {
|
||||
if err := s.Init(context.Background()); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
return s
|
||||
@@ -20,7 +23,7 @@ func newTestStore(t *testing.T) *SQLStore {
|
||||
func TestSiteCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
sites, err := s.GetSites()
|
||||
sites, err := s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
@@ -28,11 +31,11 @@ func TestSiteCRUD(t *testing.T) {
|
||||
t.Fatalf("expected 0 sites, got %d", len(sites))
|
||||
}
|
||||
|
||||
if err := s.AddSite(models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
|
||||
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
|
||||
sites, err = s.GetSites()
|
||||
sites, err = s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
@@ -44,81 +47,39 @@ func TestSiteCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
sites[0].Name = "Updated"
|
||||
if err := s.UpdateSite(sites[0]); err != nil {
|
||||
if err := s.UpdateSite(context.Background(), sites[0]); err != nil {
|
||||
t.Fatalf("UpdateSite: %v", err)
|
||||
}
|
||||
|
||||
sites, _ = s.GetSites()
|
||||
sites, err = s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
if sites[0].Name != "Updated" {
|
||||
t.Errorf("expected name 'Updated', got '%s'", sites[0].Name)
|
||||
}
|
||||
|
||||
if err := s.DeleteSite(sites[0].ID); err != nil {
|
||||
if err := s.DeleteSite(context.Background(), sites[0].ID); err != nil {
|
||||
t.Fatalf("DeleteSite: %v", err)
|
||||
}
|
||||
|
||||
sites, _ = s.GetSites()
|
||||
sites, err = s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
if len(sites) != 0 {
|
||||
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) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.AddUser("admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
|
||||
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
|
||||
t.Fatalf("AddUser: %v", err)
|
||||
}
|
||||
|
||||
users, err := s.GetAllUsers()
|
||||
users, err := s.GetAllUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllUsers: %v", err)
|
||||
}
|
||||
@@ -129,20 +90,26 @@ func TestUserCRUD(t *testing.T) {
|
||||
t.Errorf("expected username 'admin', got '%s'", users[0].Username)
|
||||
}
|
||||
|
||||
if err := s.UpdateUser(users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
|
||||
if err := s.UpdateUser(context.Background(), users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
|
||||
users, _ = s.GetAllUsers()
|
||||
users, err = s.GetAllUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllUsers: %v", err)
|
||||
}
|
||||
if users[0].Username != "root" {
|
||||
t.Errorf("expected username 'root', got '%s'", users[0].Username)
|
||||
}
|
||||
|
||||
if err := s.DeleteUser(users[0].ID); err != nil {
|
||||
if err := s.DeleteUser(context.Background(), users[0].ID); err != nil {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
|
||||
users, _ = s.GetAllUsers()
|
||||
users, err = s.GetAllUsers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllUsers: %v", err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("expected 0 users after delete, got %d", len(users))
|
||||
}
|
||||
@@ -151,11 +118,14 @@ func TestUserCRUD(t *testing.T) {
|
||||
func TestPushTokenGeneration(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.AddSite(models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
|
||||
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
|
||||
sites, _ := s.GetSites()
|
||||
sites, err := s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
if len(sites) != 1 {
|
||||
t.Fatalf("expected 1 site, got %d", len(sites))
|
||||
}
|
||||
@@ -170,11 +140,17 @@ func TestPushTokenGeneration(t *testing.T) {
|
||||
func TestImportExport(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
s.AddAlert("Test Alert", "webhook", map[string]string{"url": "https://example.com"})
|
||||
s.AddSite(models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30})
|
||||
s.AddUser("user1", "ssh-ed25519 KEY", "user")
|
||||
if err := s.AddAlert(context.Background(), "Test Alert", "webhook", map[string]string{"url": "https://example.com"}); err != nil {
|
||||
t.Fatalf("AddAlert: %v", err)
|
||||
}
|
||||
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()
|
||||
backup, err := s.ExportData(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ExportData: %v", err)
|
||||
}
|
||||
@@ -183,49 +159,121 @@ func TestImportExport(t *testing.T) {
|
||||
}
|
||||
|
||||
s2 := newTestStore(t)
|
||||
if err := s2.ImportData(backup); err != nil {
|
||||
if err := s2.ImportData(context.Background(), backup); err != nil {
|
||||
t.Fatalf("ImportData: %v", err)
|
||||
}
|
||||
|
||||
sites, _ := s2.GetSites()
|
||||
alerts, _ := s2.GetAllAlerts()
|
||||
users, _ := s2.GetAllUsers()
|
||||
sites, err := s2.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHistory(t *testing.T) {
|
||||
func TestImportData_WipesHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.SaveCheck(1, 5000000, true); err != nil {
|
||||
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "OldSite", URL: "https://old.com", Type: "http", Interval: 30}); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(context.Background(), 1, 5000, true); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(1, 10000000, false); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
if err := s.SaveStateChange(context.Background(), 1, "UP", "DOWN", "timeout"); err != nil {
|
||||
t.Fatalf("SaveStateChange: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(2, 3000000, true); err != nil {
|
||||
t.Fatalf("SaveCheck site 2: %v", err)
|
||||
if err := s.SaveAlertHealth(context.Background(), models.AlertHealthRecord{AlertID: 1, LastSendOK: true, SendCount: 1}); err != nil {
|
||||
t.Fatalf("SaveAlertHealth: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(10)
|
||||
backup := models.Backup{
|
||||
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 {
|
||||
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]))
|
||||
if len(history) != 0 {
|
||||
t.Errorf("expected empty check_history after import, got %d sites with history", len(history))
|
||||
}
|
||||
|
||||
upCount := 0
|
||||
for _, r := range history[1] {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
changes, err := s.GetStateChanges(context.Background(), 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStateChanges: %v", err)
|
||||
}
|
||||
if upCount != 1 {
|
||||
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("expected empty state_changes after import, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportData_NilUsersPreservesExisting(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +1,86 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Init() error
|
||||
Init(ctx context.Context) error
|
||||
|
||||
// Sites
|
||||
GetSites() ([]models.Site, error)
|
||||
AddSite(site models.Site) error
|
||||
UpdateSite(site models.Site) error
|
||||
UpdateSitePaused(id int, paused bool) error
|
||||
DeleteSite(id int) error
|
||||
GetSites(ctx context.Context) ([]models.SiteConfig, error)
|
||||
AddSite(ctx context.Context, site models.SiteConfig) error
|
||||
UpdateSite(ctx context.Context, site models.SiteConfig) error
|
||||
UpdateSitePaused(ctx context.Context, id int, paused bool) error
|
||||
DeleteSite(ctx context.Context, id int) error
|
||||
|
||||
// Alerts
|
||||
GetAllAlerts() ([]models.AlertConfig, error)
|
||||
GetAlert(id int) (models.AlertConfig, error)
|
||||
AddAlert(name, aType string, settings map[string]string) error
|
||||
UpdateAlert(id int, name, aType string, settings map[string]string) error
|
||||
DeleteAlert(id int) error
|
||||
GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error)
|
||||
GetAlert(ctx context.Context, id int) (models.AlertConfig, error)
|
||||
AddAlert(ctx context.Context, name, aType string, settings map[string]string) error
|
||||
UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error
|
||||
DeleteAlert(ctx context.Context, id int) error
|
||||
|
||||
// Declarative config support
|
||||
GetSiteByName(name string) (models.Site, error)
|
||||
GetAlertByName(name string) (models.AlertConfig, error)
|
||||
AddSiteReturningID(site models.Site) (int, error)
|
||||
AddAlertReturningID(name, aType string, settings map[string]string) (int, error)
|
||||
GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error)
|
||||
GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error)
|
||||
AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error)
|
||||
AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error)
|
||||
|
||||
// Users
|
||||
GetAllUsers() ([]models.User, error)
|
||||
AddUser(username, publicKey, role string) error
|
||||
UpdateUser(id int, username, publicKey, role string) error
|
||||
DeleteUser(id int) error
|
||||
GetAllUsers(ctx context.Context) ([]models.User, error)
|
||||
AddUser(ctx context.Context, username, publicKey, role string) error
|
||||
UpdateUser(ctx context.Context, id int, username, publicKey, role string) error
|
||||
DeleteUser(ctx context.Context, id int) error
|
||||
|
||||
// History
|
||||
SaveCheck(siteID int, latencyNs int64, isUp bool) error
|
||||
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
|
||||
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
|
||||
SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error
|
||||
SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error
|
||||
LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error)
|
||||
PruneCheckHistory(ctx context.Context) error
|
||||
|
||||
// State Changes
|
||||
SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error
|
||||
GetStateChanges(ctx context.Context, 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
|
||||
RegisterNode(node models.ProbeNode) error
|
||||
GetNode(id string) (models.ProbeNode, error)
|
||||
GetAllNodes() ([]models.ProbeNode, error)
|
||||
UpdateNodeLastSeen(id string) error
|
||||
DeleteNode(id string) error
|
||||
RegisterNode(ctx context.Context, node models.ProbeNode) error
|
||||
GetNode(ctx context.Context, id string) (models.ProbeNode, error)
|
||||
GetAllNodes(ctx context.Context) ([]models.ProbeNode, error)
|
||||
UpdateNodeLastSeen(ctx context.Context, id string) error
|
||||
DeleteNode(ctx context.Context, id string) error
|
||||
|
||||
// Alert Health
|
||||
LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error)
|
||||
SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error
|
||||
|
||||
// Logs
|
||||
SaveLog(message string) error
|
||||
LoadLogs(limit int) ([]string, error)
|
||||
SaveLog(ctx context.Context, message string) error
|
||||
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
|
||||
PruneLogs(ctx context.Context) error
|
||||
|
||||
// Maintenance Windows
|
||||
GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
|
||||
GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
|
||||
AddMaintenanceWindow(mw models.MaintenanceWindow) error
|
||||
EndMaintenanceWindow(id int) error
|
||||
DeleteMaintenanceWindow(id int) error
|
||||
IsMonitorInMaintenance(monitorID int) (bool, error)
|
||||
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error)
|
||||
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
|
||||
GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error)
|
||||
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error
|
||||
EndMaintenanceWindow(ctx context.Context, id int) error
|
||||
DeleteMaintenanceWindow(ctx context.Context, id int) error
|
||||
PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error)
|
||||
IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error)
|
||||
|
||||
// Preferences
|
||||
GetPreference(key string) (string, error)
|
||||
SetPreference(key, value string) error
|
||||
GetPreference(ctx context.Context, key string) (string, error)
|
||||
SetPreference(ctx context.Context, key, value string) error
|
||||
|
||||
// Backup & Restore
|
||||
ExportData() (models.Backup, error)
|
||||
ImportData(data models.Backup) error
|
||||
ExportData(ctx context.Context) (models.Backup, error)
|
||||
ImportData(ctx context.Context, data models.Backup) error
|
||||
|
||||
// Lifecycle
|
||||
Close() error
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
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)
|
||||
}
|
||||