1 Commits

Author SHA1 Message Date
lerko 3480679176 feat(tui): add SLA reporting view
CI / test (pull_request) Successful in 2m37s
CI / lint (pull_request) Successful in 57s
CI / vulncheck (pull_request) Successful in 51s
Full-screen SLA report accessible via [s] from detail panel.
Computes uptime%, downtime, outage count, longest outage, MTTR,
and MTBF from state_changes table. Includes daily breakdown with
bar chart, switchable time periods (24h/7d/30d/90d), and
scrollable viewport. LATE/STALE treated as UP for SLA purposes.
2026-06-04 13:32:15 -04:00
82 changed files with 2341 additions and 6293 deletions
-2
View File
@@ -10,5 +10,3 @@ vendor/
*.local *.local
.env .env
.github/ .github/
dist/
uptop
-46
View File
@@ -1,46 +0,0 @@
name: Bug Report
about: Something isn't working as expected
labels:
- bug
body:
- type: checkboxes
id: search
attributes:
label: Before filing
options:
- label: I searched existing issues and didn't find a match
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: Include what you expected to happen instead.
placeholder: |
When I run `uptop serve`, 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 serve`
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 version 2026.06.1
OS: Debian 13
Terminal: Ghostty
[paste any error output here]
validations:
required: false
@@ -1,20 +0,0 @@
name: Feature Request
about: Suggest a new feature or enhancement
labels:
- feature
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What's frustrating or missing?
placeholder: I find myself always needing to ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: How would you like this to work?
validations:
required: false
+1 -1
View File
@@ -65,7 +65,7 @@ jobs:
go-version: "1.26" go-version: "1.26"
- name: Install govulncheck - name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck - name: Run govulncheck
run: govulncheck ./... run: govulncheck ./...
+4 -8
View File
@@ -3,7 +3,7 @@ name: Release Binaries
on: on:
push: push:
tags: tags:
- "v[0-9]*" - "[0-9]*"
jobs: jobs:
release: release:
@@ -13,7 +13,7 @@ jobs:
shell: sh shell: sh
steps: steps:
- name: Install build tools - name: Install build tools
run: apk add --no-cache git run: apk add --no-cache git gcc musl-dev
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -33,8 +33,8 @@ jobs:
- name: Install git-cliff - name: Install git-cliff
run: | run: |
apk add --no-cache curl apk add --no-cache curl jq
VERSION=2.13.1 VERSION=$(curl -sS https://api.github.com/repos/orhun/git-cliff/releases/latest | jq -r '.tag_name' | sed 's/^v//')
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 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/ mv /tmp/git-cliff-*/git-cliff /usr/local/bin/
git-cliff --version git-cliff --version
@@ -52,7 +52,3 @@ jobs:
GORELEASER_FORCE_TOKEN: gitea GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_API_URL: http://gitea:3000/api/v1 GITEA_API_URL: http://gitea:3000/api/v1
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
# which runs on GitHub Actions when the push mirror delivers the tag and
# copies this run's Gitea release assets — no PAT needed on this side.
+8 -14
View File
@@ -3,11 +3,11 @@ name: Release Docker
on: on:
push: push:
tags: tags:
- "v[0-9]*" - "[0-9]*"
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: "Image tag (e.g. 1.0.0, no v prefix). Defaults to latest commit SHA." description: "Image tag (e.g. 2026.06.1). Defaults to latest commit SHA."
required: false required: false
jobs: jobs:
@@ -27,19 +27,10 @@ jobs:
TAG="${{ github.sha }}" TAG="${{ github.sha }}"
fi fi
else else
# Docker convention: git tag v1.2.3 -> image tag 1.2.3
TAG="${{ github.ref_name }}" TAG="${{ github.ref_name }}"
TAG="${TAG#v}"
fi fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT"
TAGS="lerkolabs/uptop:${TAG}"
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
if [ "${{ github.ref_type }}" = "tag" ]; then
TAGS="${TAGS},lerkolabs/uptop:latest"
fi
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -60,7 +51,10 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
sbom: true sbom: true
provenance: mode=max provenance: mode=max
tags: ${{ steps.meta.outputs.tags }} tags: |
lerkolabs/uptop:${{ steps.meta.outputs.tag }}
lerkolabs/uptop:latest
lerkolabs/uptop:sha-${{ steps.meta.outputs.short_sha }}
build-args: | build-args: |
VERSION=${{ steps.meta.outputs.tag }} VERSION=${{ steps.meta.outputs.tag }}
COMMIT=${{ github.sha }} COMMIT=${{ github.sha }}
@@ -68,8 +62,8 @@ jobs:
- name: Scan image for CVEs - name: Scan image for CVEs
run: | run: |
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.114.0 curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
grype lerkolabs/uptop:${{ steps.meta.outputs.tag }} --fail-on critical --output table grype lerkolabs/uptop:${{ steps.meta.outputs.tag }} --fail-on critical --output table || echo "::warning::CVE scan found critical issues — review output above"
- name: Update Docker Hub description - name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4 uses: peter-evans/dockerhub-description@v4
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Report a Bug
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=bug_report.yaml
about: Report bugs on our Gitea instance
- name: Request a Feature
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=feature_request.yaml
about: Suggest features on our Gitea instance
+2 -7
View File
@@ -3,7 +3,7 @@ name: Mirror Release to GitHub
on: on:
push: push:
tags: tags:
- "v[0-9]*" - "[0-9]*"
permissions: permissions:
contents: write contents: write
@@ -38,9 +38,7 @@ jobs:
exit 1 exit 1
fi fi
# select() so an empty-string body produces an empty file — `// empty` echo "$RESPONSE" | jq -r '.body // empty' > /tmp/release-notes.md
# treats "" as truthy and wrote a blank line, defeating this fallback.
echo "$RESPONSE" | jq -r '.body | select(. != null and . != "")' > /tmp/release-notes.md
if [ ! -s /tmp/release-notes.md ]; then if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md
@@ -64,11 +62,8 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
PRERELEASE=""
case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac
gh release create "$TAG" \ gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \ --repo "$GITHUB_REPOSITORY" \
--title "$TAG" \ --title "$TAG" \
--notes-file /tmp/release-notes.md \ --notes-file /tmp/release-notes.md \
$PRERELEASE \
/tmp/assets/* /tmp/assets/*
+21 -13
View File
@@ -8,20 +8,16 @@ release:
gitea: gitea:
owner: lerkolabs owner: lerkolabs
name: uptop name: uptop
prerelease: auto
builds: builds:
- main: ./cmd/uptop - main: ./cmd/uptop/main.go
binary: uptop binary: uptop
env: env:
- CGO_ENABLED=0 - CGO_ENABLED=1
goos: goos:
- linux - linux
- darwin
- windows
goarch: goarch:
- amd64 - amd64
- arm64
ldflags: ldflags:
- -s -w - -s -w
- -X main.version={{ .Version }} - -X main.version={{ .Version }}
@@ -33,9 +29,6 @@ builds:
archives: archives:
- formats: [tar.gz] - formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
checksum: checksum:
name_template: checksums.txt name_template: checksums.txt
@@ -59,7 +52,22 @@ nfpms:
dst: /usr/share/doc/uptop/LICENSE dst: /usr/share/doc/uptop/LICENSE
type: doc type: doc
# Changelog generation must stay enabled: the --release-notes flag is consumed homebrew_casks:
# by the changelog pipe, so disabling it silently drops the git-cliff notes - name: uptop
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser homepage: https://gitea.lerkolabs.com/lerkolabs/uptop
# skips its own generation and uses the file. description: Self-hosted uptime monitoring with a TUI over SSH
directory: Casks
skip_upload: true
commit_msg_template: "update uptop to {{ .Tag }}"
url:
template: "https://gitea.lerkolabs.com/lerkolabs/uptop/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
repository:
owner: lerkolabs
name: homebrew-tap
git:
url: "ssh://git@gitea.lerkolabs.com:2222/lerkolabs/homebrew-tap.git"
private_key: "{{ if index .Env \"TAP_SSH_KEY\" }}{{ .Env.TAP_SSH_KEY }}{{ end }}"
ssh_command: "ssh -o StrictHostKeyChecking=accept-new"
changelog:
disable: true
-6
View File
@@ -1,6 +0,0 @@
ignore:
# CVE-2026-41589: SCP path traversal in charmbracelet/wish.
# We only import wish/bubbletea for the SSH TUI server — the vulnerable
# scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in.
# No fix available for wish v1; v2 (charm.land/wish/v2) patched in 2.0.1.
- vulnerability: CVE-2026-41589
+4 -5
View File
@@ -1,17 +1,18 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder FROM golang:1.26-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
go mod download go mod download
COPY . . COPY . .
ENV CGO_ENABLED=0 ENV CGO_ENABLED=1
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=none ARG COMMIT=none
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go
# --- Stage 2: Runner --- # --- Stage 2: Runner ---
FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 FROM alpine:3.23@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11
@@ -31,8 +32,6 @@ ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
ENV UPTOP_PORT=23234 ENV UPTOP_PORT=23234
EXPOSE 23234 EXPOSE 23234
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/health || exit 1
USER uptop USER uptop
ENTRYPOINT ["docker-entrypoint.sh"] ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["./uptop"] CMD ["./uptop"]
+2 -34
View File
@@ -22,7 +22,7 @@ Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten fo
## Features ## Features
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups - **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie - **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 - **Config as code** — define monitors in YAML, apply declaratively, version control your setup
- **HA clustering** — leader/follower with automatic failover - **HA clustering** — leader/follower with automatic failover
- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana - **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana
@@ -30,8 +30,6 @@ Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten fo
- **SQLite or Postgres** — SQLite for single-node, Postgres for production - **SQLite or Postgres** — SQLite for single-node, Postgres for production
- **Uptime Kuma import** — migrate from Kuma with one command - **Uptime Kuma import** — migrate from Kuma with one command
> Group monitors roll up child status for display but don't fire their own alerts yet — attach alerts to the children.
## Screenshots ## Screenshots
<table> <table>
@@ -81,14 +79,10 @@ services:
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host # - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
volumes: volumes:
- ./data:/data - ./data:/data
sysctls:
- net.ipv4.ping_group_range=0 2147483647
``` ```
First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab. First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab.
The `sysctls` line enables unprivileged ICMP inside the container — without it, ping monitors get no response and silently report DOWN.
</details> </details>
<details> <details>
@@ -144,22 +138,9 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | | `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses | | `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup | | `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings. See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
### 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 ### Encryption
Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook URLs, API tokens) at rest with AES-256-GCM. Generate a key: Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook URLs, API tokens) at rest with AES-256-GCM. Generate a key:
@@ -168,19 +149,6 @@ Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook
Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set. Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set.
### Data retention
uptop prunes its own history in the background — no external cleanup jobs needed:
| Data | Kept |
|---|---|
| Check history | newest 1,000 checks per monitor |
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
| Logs | newest 200 entries |
| Maintenance windows | 7 days after they end (configurable) |
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
## Clustering ## Clustering
uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions). uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions).
@@ -193,7 +161,7 @@ Export your Kuma backup JSON, then:
```bash ```bash
curl -X POST http://localhost:8080/api/import/kuma \ curl -X POST http://localhost:8080/api/import/kuma \
-H "X-Uptop-Secret: your-secret" \ -H "X-Upkeep-Secret: your-secret" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d @kuma-backup.json -d @kuma-backup.json
``` ```
+1 -1
View File
@@ -23,7 +23,7 @@ filter_unconventional = true
split_commits = false split_commits = false
protect_breaking_commits = false protect_breaking_commits = false
filter_commits = false filter_commits = false
tag_pattern = "v[0-9].*" tag_pattern = "[0-9]*"
topo_order = false topo_order = false
sort_commits = "oldest" sort_commits = "oldest"
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"net"
"os"
"strconv"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
)
type appConfig struct {
Port int
SSHHostKey string
DBType string
DBDSN string
HTTPPort int
TLSCert string
TLSKey string
StatusEnabled bool
StatusTitle string
ClusterMode string
ClusterSecret string
PeerURL string
NodeID string
NodeName string
NodeRegion string
AggStrategy string
AllowPrivateTargets bool
InsecureSkipVerify bool
MaintRetention time.Duration
EncryptionKey string
MetricsPublic bool
CORSOrigin string
TrustedProxies []*net.IPNet
AdminKey string
KeysFile string
}
func parseConfig() appConfig {
cfg := appConfig{
Port: 23234,
SSHHostKey: ".ssh/id_ed25519",
DBType: "sqlite",
DBDSN: "uptop.db",
HTTPPort: 8080,
StatusTitle: "System Status",
ClusterMode: "leader",
MaintRetention: 7 * 24 * time.Hour,
}
if v := os.Getenv("UPTOP_PORT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.Port = n
}
}
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
cfg.DBType = v
}
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
cfg.DBDSN = v
}
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
cfg.HTTPPort = n
}
}
if os.Getenv("UPTOP_STATUS_ENABLED") == "true" {
cfg.StatusEnabled = true
}
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
cfg.StatusTitle = v
}
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
cfg.ClusterMode = v
}
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
cfg.PeerURL = v
}
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
cfg.ClusterSecret = v
}
cfg.NodeID = os.Getenv("UPTOP_NODE_ID")
cfg.NodeName = os.Getenv("UPTOP_NODE_NAME")
cfg.NodeRegion = os.Getenv("UPTOP_NODE_REGION")
cfg.AggStrategy = os.Getenv("UPTOP_AGG_STRATEGY")
cfg.AllowPrivateTargets = os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
cfg.InsecureSkipVerify = os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true"
cfg.MetricsPublic = os.Getenv("UPTOP_METRICS_PUBLIC") == "true"
cfg.EncryptionKey = os.Getenv("UPTOP_ENCRYPTION_KEY")
cfg.TLSCert = os.Getenv("UPTOP_TLS_CERT")
cfg.TLSKey = os.Getenv("UPTOP_TLS_KEY")
cfg.CORSOrigin = os.Getenv("UPTOP_CORS_ORIGIN")
cfg.TrustedProxies = parseTrustedProxies(os.Getenv("UPTOP_TRUSTED_PROXIES"))
cfg.SSHHostKey = envOrDefault("UPTOP_SSH_HOST_KEY", cfg.SSHHostKey)
cfg.AdminKey = os.Getenv("UPTOP_ADMIN_KEY")
cfg.KeysFile = os.Getenv("UPTOP_KEYS")
if v := os.Getenv("UPTOP_MAINT_RETENTION"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.MaintRetention = d
}
}
return cfg
}
func (c appConfig) serverConfig(quietHTTPLog bool) server.ServerConfig {
return server.ServerConfig{
Port: c.HTTPPort,
EnableStatus: c.StatusEnabled,
Title: c.StatusTitle,
ClusterKey: c.ClusterSecret,
TLSCert: c.TLSCert,
TLSKey: c.TLSKey,
ClusterMode: c.ClusterMode,
MetricsPublic: c.MetricsPublic,
CORSOrigin: c.CORSOrigin,
TrustedProxies: c.TrustedProxies,
QuietHTTPLog: quietHTTPLog,
}
}
-115
View File
@@ -1,115 +0,0 @@
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
"github.com/charmbracelet/ssh"
gossh "golang.org/x/crypto/ssh"
)
// kcMockStore embeds BaseMock for default no-ops; only GetAllUsers is
// overridden because the tests mutate users/err between calls.
type kcMockStore struct {
storetest.BaseMock
users []models.User
err error
}
func (m *kcMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, m.err }
func testKey(t *testing.T) (string, ssh.PublicKey) {
t.Helper()
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sk, err := gossh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return string(gossh.MarshalAuthorizedKey(sk)), sk
}
func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
authorized, known := testKey(t)
_, unknown := testKey(t)
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
if !kc.IsAllowed(known) {
t.Error("known key denied")
}
if kc.IsAllowed(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(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(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(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(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(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(known) {
t.Error("deleted user's key still allowed from stale cache")
}
}
+151 -177
View File
@@ -6,12 +6,12 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log"
"net"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -40,9 +40,7 @@ var (
) )
func main() { func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ log.SetOutput(os.Stderr)
Level: slog.LevelInfo,
})))
if len(os.Args) >= 2 { if len(os.Args) >= 2 {
switch os.Args[1] { switch os.Args[1] {
@@ -87,39 +85,6 @@ func redactDSN(dsn string) string {
return u.String() return u.String()
} }
// 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(dbType, dsn string) store.Store { func openStore(dbType, dsn string) store.Store {
var ss *store.SQLStore var ss *store.SQLStore
var err error var err error
@@ -129,21 +94,21 @@ func openStore(dbType, dsn string) store.Store {
ss, err = store.NewSQLiteStore(dsn) ss, err = store.NewSQLiteStore(dsn)
} }
if err != nil { if err != nil {
slog.Error("database connection failed", "err", err) fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" { if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(encKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
} else { } else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted") fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
} }
if err := ss.Init(context.Background()); err != nil { if err := ss.Init(); err != nil {
slog.Error("database init failed", "err", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
return ss return ss
@@ -168,16 +133,16 @@ func runApply(args []string) {
f, err := config.LoadFile(*filePath) f, err := config.LoadFile(*filePath)
if err != nil { if err != nil {
slog.Error("config load failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{ changes, err := config.Apply(s, f, config.ApplyOpts{
DryRun: *dryRun, DryRun: *dryRun,
Prune: *prune, Prune: *prune,
}) })
if err != nil { if err != nil {
slog.Error("config apply failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -193,14 +158,14 @@ func runExport(args []string) {
s := openStore(*dbType, *dsn) s := openStore(*dbType, *dsn)
f, err := config.Export(context.Background(), s) f, err := config.Export(s)
if err != nil { if err != nil {
slog.Error("export failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := config.WriteFile(f, *outPath); err != nil { if err := config.WriteFile(f, *outPath); err != nil {
slog.Error("export write failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }
@@ -218,7 +183,7 @@ func runMigrateSecrets(args []string) {
} }
enc, err := store.NewEncryptor(encKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -229,25 +194,25 @@ func runMigrateSecrets(args []string) {
ss, err = store.NewSQLiteStore(*dsn) ss, err = store.NewSQLiteStore(*dsn)
} }
if err != nil { if err != nil {
slog.Error("database connection failed", "err", err) fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := ss.Init(context.Background()); err != nil { if err := ss.Init(); err != nil {
slog.Error("database init failed", "err", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
alerts, err := ss.GetAllAlerts()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading alerts: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
alerts, err := ss.GetAllAlerts(context.Background())
if err != nil {
slog.Error("failed to load alerts", "err", err)
os.Exit(1)
}
migrated := 0 migrated := 0
for _, a := range alerts { for _, a := range alerts {
if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil { if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil {
slog.Error("alert migration failed", "alert", a.Name, "err", err) fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err)
os.Exit(1) os.Exit(1)
} }
migrated++ migrated++
@@ -256,19 +221,64 @@ func runMigrateSecrets(args []string) {
} }
func runServe(args []string) { func runServe(args []string) {
cfg := parseConfig() portVal := 23234
dbType := "sqlite"
dbDSN := "uptop.db"
httpPort := 8080
enableStatus := false
statusTitle := "System Status"
clusterMode := "leader"
clusterPeer := ""
clusterKey := ""
if cfg.ClusterMode == "probe" { if v := os.Getenv("UPTOP_PORT"); v != "" {
if cfg.NodeID == "" { if p, err := strconv.Atoi(v); err == nil {
portVal = p
}
}
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
dbType = v
}
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
dbDSN = v
}
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
httpPort = p
}
}
if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
enableStatus = true
}
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
statusTitle = v
}
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
clusterMode = v
}
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
clusterPeer = v
}
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
clusterKey = v
}
nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
if clusterMode == "probe" {
if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
os.Exit(1) os.Exit(1)
} }
if cfg.PeerURL == "" { if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", cfg.NodeID, cfg.NodeRegion) fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -279,28 +289,29 @@ func runServe(args []string) {
cancel() cancel()
}() }()
if cfg.AllowPrivateTargets { probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks") if probeAllowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
} }
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{ if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
NodeID: cfg.NodeID, NodeID: nodeID,
NodeName: cfg.NodeName, NodeName: nodeName,
Region: cfg.NodeRegion, Region: nodeRegion,
LeaderURL: cfg.PeerURL, LeaderURL: clusterPeer,
SharedKey: cfg.ClusterSecret, SharedKey: clusterKey,
Interval: 30, Interval: 30,
AllowPrivateTargets: cfg.AllowPrivateTargets, AllowPrivateTargets: probeAllowPrivate,
}); err != nil { }); err != nil {
slog.Error("probe failed", "err", err) fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
} }
return return
} }
fs := flag.NewFlagSet("serve", flag.ExitOnError) fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", cfg.Port, "SSH Port") port := fs.Int("port", portVal, "SSH Port")
flagDBType := fs.String("db-type", cfg.DBType, "Database type") flagDBType := fs.String("db-type", dbType, "Database type")
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN") flagDSN := fs.String("dsn", dbDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data") demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
@@ -309,32 +320,31 @@ func runServe(args []string) {
var dbErr error var dbErr error
if *flagDBType == "postgres" { if *flagDBType == "postgres" {
ss, dbErr = store.NewPostgresStore(*flagDSN) ss, dbErr = store.NewPostgresStore(*flagDSN)
slog.Info("database connected", "type", "postgres", "dsn", redactDSN(*flagDSN)) fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
} else { } else {
ss, dbErr = store.NewSQLiteStore(*flagDSN) ss, dbErr = store.NewSQLiteStore(*flagDSN)
slog.Info("database connected", "type", "sqlite", "dsn", *flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN)
} }
if dbErr != nil { if dbErr != nil {
slog.Error("database connection failed", "err", dbErr) fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
os.Exit(1) os.Exit(1)
} }
defer ss.Close() defer ss.Close()
if cfg.EncryptionKey != "" { if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(cfg.EncryptionKey) enc, err := store.NewEncryptor(encKey)
if err != nil { if err != nil {
slog.Error("encryption key invalid", "err", err) fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
ss.SetEncryptor(enc) ss.SetEncryptor(enc)
} else { } else {
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted") fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
} }
kc := newKeyCache(ss) var s store.Store = ss
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc} if err := s.Init(); err != nil {
if err := s.Init(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
slog.Error("database init failed", "err", err)
os.Exit(1) os.Exit(1)
} }
if *demo { if *demo {
@@ -346,29 +356,29 @@ func runServe(args []string) {
if *importKuma != "" { if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma) kb, err := importer.LoadKumaFile(*importKuma)
if err != nil { if err != nil {
slog.Error("kuma import failed", "err", err) fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
backup := importer.ConvertKuma(kb) backup := importer.ConvertKuma(kb)
if err := s.ImportData(context.Background(), backup); err != nil { if err := s.ImportData(backup); err != nil {
slog.Error("import failed", "err", err) fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
} }
if cfg.AllowPrivateTargets { allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
slog.Warn("private target blocking disabled, monitor URLs can reach internal networks") if allowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
} }
eng := monitor.NewEngineWithOpts(s, cfg.AllowPrivateTargets) eng := monitor.NewEngineWithOpts(s, allowPrivate)
if cfg.InsecureSkipVerify { if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true) eng.SetInsecureSkipVerify(true)
} }
if cfg.AggStrategy != "" { if aggStrategy != "" {
eng.SetAggStrategy(monitor.AggregationStrategy(cfg.AggStrategy)) eng.SetAggStrategy(monitor.AggregationStrategy(aggStrategy))
} }
eng.SetMaintRetention(cfg.MaintRetention)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -378,22 +388,34 @@ func runServe(args []string) {
eng.InitAlertHealth() eng.InitAlertHealth()
eng.Start(ctx) eng.Start(ctx)
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) tlsCert := os.Getenv("UPTOP_TLS_CERT")
tlsKey := os.Getenv("UPTOP_TLS_KEY")
httpSrv := server.Start(cfg.serverConfig(localTUI), s, eng) httpSrv := server.Start(server.ServerConfig{
Port: httpPort,
EnableStatus: enableStatus,
Title: statusTitle,
ClusterKey: clusterKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
ClusterMode: clusterMode,
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
}, s, eng)
cluster.Start(ctx, cluster.Config{ cluster.Start(ctx, cluster.Config{
Mode: cfg.ClusterMode, Mode: clusterMode,
PeerURL: cfg.PeerURL, PeerURL: clusterPeer,
SharedKey: cfg.ClusterSecret, SharedKey: clusterKey,
}, eng) }, eng)
kc := newKeyCache(s)
sshSrv := startSSHServer(*port, s, eng, kc) sshSrv := startSSHServer(*port, s, eng, kc)
if localTUI { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
slog.Error("TUI failed", "err", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
} }
} else { } else {
fmt.Println("uptop running in HEADLESS mode") fmt.Println("uptop running in HEADLESS mode")
@@ -404,18 +426,16 @@ func runServe(args []string) {
} }
cancel() cancel()
eng.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel() defer shutdownCancel()
if httpSrv != nil { if httpSrv != nil {
if err := httpSrv.Shutdown(shutdownCtx); err != nil { if err := httpSrv.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP shutdown failed", "err", err) log.Printf("HTTP shutdown error: %v", err)
} }
} }
if sshSrv != nil { if sshSrv != nil {
if err := sshSrv.Shutdown(shutdownCtx); err != nil { if err := sshSrv.Shutdown(shutdownCtx); err != nil {
slog.Error("SSH shutdown failed", "err", err) log.Printf("SSH shutdown error: %v", err)
} }
} }
} }
@@ -429,54 +449,53 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
}), }),
wish.WithMiddleware( wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} return tui.InitialModel(false, db, eng), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}), }),
), ),
) )
if err != nil { if err != nil {
slog.Error("SSH server failed", "err", err) fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
return nil return nil
} }
go func() { go func() {
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) { if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
slog.Error("SSH server failed", "err", err) log.Printf("SSH server error: %v", err)
} }
}() }()
return s return s
} }
func seedDemoData(s store.Store) { func seedDemoData(s store.Store) {
ctx := context.Background() existing, _ := s.GetSites()
existing, _ := s.GetSites(ctx)
if len(existing) > 0 { if len(existing) > 0 {
return return
} }
fmt.Println("Seeding demo data...") fmt.Println("Seeding demo data...")
if err := s.AddAlert(ctx, "Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil { if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
if err := s.AddAlert(ctx, "Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil { if err := s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
if err := s.AddAlert(ctx, "Email Oncall", "email", map[string]string{ if err := s.AddAlert("Email Oncall", "email", map[string]string{
"host": "smtp.example.com", "port": "587", "host": "smtp.example.com", "port": "587",
"user": "oncall@example.com", "pass": "replace-me", "user": "oncall@example.com", "pass": "replace-me",
"from": "oncall@example.com", "to": "team@example.com", "from": "oncall@example.com", "to": "team@example.com",
}); err != nil { }); err != nil {
slog.Error("demo seed failed", "step", "add alert", "err", err) log.Printf("demo seed: add alert: %v", err)
return return
} }
alerts, _ := s.GetAllAlerts(ctx) alerts, _ := s.GetAllAlerts()
alertID := 0 alertID := 0
if len(alerts) > 0 { if len(alerts) > 0 {
alertID = alerts[0].ID alertID = alerts[0].ID
} }
demoSites := []models.SiteConfig{ demoSites := []models.Site{
{Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2}, {Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2},
{Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}, {Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3},
{Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}, {Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1},
@@ -489,8 +508,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}, {Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7},
} }
for _, site := range demoSites { for _, site := range demoSites {
if err := s.AddSite(ctx, site); err != nil { if err := s.AddSite(site); err != nil {
slog.Error("demo seed failed", "step", "add site", "site", site.Name, "err", err) log.Printf("demo seed: add site %q: %v", site.Name, err)
} }
} }
} }
@@ -508,12 +527,8 @@ func newKeyCache(db store.Store) *keyCache {
} }
func (c *keyCache) refresh() { func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers(context.Background()) users, err := c.db.GetAllUsers()
if err != nil { if err != nil {
// Keep the previous key set: a transient DB error must not lock every
// admin out. Revocation still fails closed because Invalidate clears
// the set immediately.
slog.Error("SSH key cache refresh failed", "err", err)
return return
} }
keys := make([]ssh.PublicKey, 0, len(users)) keys := make([]ssh.PublicKey, 0, len(users))
@@ -530,13 +545,8 @@ func (c *keyCache) refresh() {
c.mu.Unlock() c.mu.Unlock()
} }
// Invalidate clears the cached key set, not just the timestamp. If the
// refresh that follows a user revocation fails, auth fails closed (everyone
// re-authenticates after the next successful refresh) instead of the revoked
// key silently continuing to work off the stale cache.
func (c *keyCache) Invalidate() { func (c *keyCache) Invalidate() {
c.mu.Lock() c.mu.Lock()
c.keys = nil
c.updated = time.Time{} c.updated = time.Time{}
c.mu.Unlock() c.mu.Unlock()
} }
@@ -560,41 +570,7 @@ func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
return false return false
} }
// 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(s store.Store) { func seedKeysFromEnv(s store.Store) {
ctx := context.Background()
var keys []string var keys []string
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" { if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
@@ -603,9 +579,7 @@ func seedKeysFromEnv(s store.Store) {
if path := os.Getenv("UPTOP_KEYS"); path != "" { if path := os.Getenv("UPTOP_KEYS"); path != "" {
f, err := os.Open(filepath.Clean(path)) f, err := os.Open(filepath.Clean(path))
if err != nil { if err == nil {
slog.Warn("failed to open UPTOP_KEYS file", "path", path, "err", err) //nolint:gosec // structured slog, not format string
} else {
scanner := bufio.NewScanner(f) scanner := bufio.NewScanner(f)
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
@@ -622,9 +596,9 @@ func seedKeysFromEnv(s store.Store) {
return return
} }
existing, err := s.GetAllUsers(ctx) existing, err := s.GetAllUsers()
if err != nil { if err != nil {
slog.Warn("could not check existing users", "err", err) fmt.Fprintf(os.Stderr, "warning: could not check existing users: %v\n", err)
return return
} }
@@ -640,8 +614,8 @@ func seedKeysFromEnv(s store.Store) {
} }
username := usernameFromKey(key, i, len(existing)+added) username := usernameFromKey(key, i, len(existing)+added)
if err := s.AddUser(ctx, username, key, "admin"); err != nil { if err := s.AddUser(username, key, "admin"); err != nil {
slog.Warn("failed to seed user", "user", username, "err", err) //nolint:gosec // structured slog, not format string fmt.Fprintf(os.Stderr, "warning: failed to seed user %q: %v\n", username, err)
continue continue
} }
fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != "")) fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != ""))
+2 -2
View File
@@ -18,7 +18,7 @@ services:
# Cluster Config # Cluster Config
- UPTOP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=mysecret
depends_on: depends_on:
- leader-db - leader-db
stdin_open: true stdin_open: true
@@ -53,7 +53,7 @@ services:
# Cluster Config # Cluster Config
- UPTOP_CLUSTER_MODE=follower - UPTOP_CLUSTER_MODE=follower
- UPTOP_CLUSTER_SECRET=mysecret # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally # IMPORTANT: Uses the Service Name "leader" to connect internally
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
depends_on: depends_on:
+3 -3
View File
@@ -3,7 +3,7 @@ services:
build: . build: .
environment: environment:
- UPTOP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme
- UPTOP_AGG_STRATEGY=any-down - UPTOP_AGG_STRATEGY=any-down
- UPTOP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
ports: ports:
@@ -18,7 +18,7 @@ services:
- UPTOP_NODE_NAME=US East Probe - UPTOP_NODE_NAME=US East Probe
- UPTOP_NODE_REGION=us-east - UPTOP_NODE_REGION=us-east
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
@@ -30,6 +30,6 @@ services:
- UPTOP_NODE_NAME=EU West Probe - UPTOP_NODE_NAME=EU West Probe
- UPTOP_NODE_REGION=eu-west - UPTOP_NODE_REGION=eu-west
- UPTOP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
-7
View File
@@ -5,13 +5,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: uptop container_name: uptop
restart: unless-stopped restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
ports: ports:
- "23234:23234" - "23234:23234"
- "8080:8080" - "8080:8080"
+1 -6
View File
@@ -16,11 +16,6 @@ A follower is a standby replica that takes over if the leader goes down.
- When the leader recovers, the follower detects it and goes back to standby - When the leader recovers, the follower detects it and goes back to standby
- Both nodes have their own database — they do not share state - 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:** **Required env vars:**
| Node | Variable | Value | | Node | Variable | Value |
@@ -81,5 +76,5 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
## Security ## Security
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated. - Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated.
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production. - Secrets are sent in HTTP headers (`X-Upkeep-Secret`). Use TLS or a reverse proxy for production.
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS. - uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
+2 -31
View File
@@ -122,7 +122,7 @@ Groups can't nest inside other groups. A group is healthy when all its children
## Alert types ## Alert types
All 10 providers work in the YAML. The `settings` map is different per type. All 9 providers work in the YAML. The `settings` map is different per type.
```yaml ```yaml
# Discord / Slack / Generic Webhook — just a URL # Discord / Slack / Generic Webhook — just a URL
@@ -149,9 +149,6 @@ All 10 providers work in the YAML. The `settings` map is different per type.
url: https://ntfy.sh url: https://ntfy.sh
topic: my-alerts topic: my-alerts
priority: "4" priority: "4"
# for protected topics:
# username: user
# password: pass
# Telegram # Telegram
- name: Telegram Ops - name: Telegram Ops
@@ -181,14 +178,6 @@ All 10 providers work in the YAML. The `settings` map is different per type.
url: https://gotify.example.com url: https://gotify.example.com
token: app-token token: app-token
priority: "8" priority: "8"
# Opsgenie
- name: Opsgenie
type: opsgenie
settings:
api_key: your-api-key
priority: P2 # P1P5, default P3
# eu: "true" # use the EU API endpoint
``` ```
## Commands ## Commands
@@ -235,25 +224,7 @@ Monitors and alerts are matched by **name**. Names must be unique across the ent
Apply is idempotent. Run it twice with the same file, second run changes nothing. Apply is idempotent. Run it twice with the same file, second run changes nothing.
Apply is **not atomic** — items are written one at a time, so an error mid-apply (bad value, lost DB connection, ctrl-C) leaves the items already written in place. That's safe to recover from: apply diffs against the database by name, so fix the issue and run it again — it converges the rest. Just don't run two applies against the same database at once. If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
## Backups and secrets
`uptop export` writes alert credentials (SMTP passwords, API tokens, webhook URLs) into the YAML in clear text — that's what makes the file restorable. Treat it like a secrets file.
The HTTP export endpoint redacts those same fields **by default**:
```bash
# secrets show as ***REDACTED*** — fine for sharing or review
curl -H "X-Uptop-Secret: your-secret" \
"http://localhost:8080/api/backup/export"
# full backup you can actually restore from
curl -H "X-Uptop-Secret: your-secret" \
"http://localhost:8080/api/backup/export?redact_secrets=false"
```
Restoring a redacted export imports the literal string `***REDACTED***` as your credentials. For real backups, pass `redact_secrets=false` or run `uptop export` on the host.
## Typical workflow ## Typical workflow
+2 -7
View File
@@ -13,11 +13,10 @@ require (
github.com/lib/pq v1.11.1 github.com/lib/pq v1.11.1
github.com/lrstanley/bubblezone v1.0.0 github.com/lrstanley/bubblezone v1.0.0
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-sqlite3 v1.14.33
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus-community/pro-bing v0.8.0
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.52.0
) )
require ( require (
@@ -49,10 +48,9 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
@@ -60,7 +58,4 @@ require (
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.44.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
+2 -36
View File
@@ -64,12 +64,8 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA=
@@ -82,6 +78,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -92,14 +90,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -131,31 +125,3 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+4 -115
View File
@@ -3,14 +3,10 @@ package alert
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/smtp" "net/smtp"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -20,22 +16,6 @@ import (
var alertClient = &http.Client{Timeout: 10 * time.Second} var alertClient = &http.Client{Timeout: 10 * time.Second}
// sanitizeError strips the request URL from transport errors before they are
// stored or displayed. *url.Error embeds the full URL, which for several
// providers carries the credential itself (Telegram bot token in the path,
// webhook secrets in the URL). The operation and underlying cause — the useful
// diagnostic — are preserved.
func sanitizeError(err error) error {
if err == nil {
return nil
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
return fmt.Errorf("%s request failed: %w", urlErr.Op, urlErr.Err)
}
return err
}
type Provider interface { type Provider interface {
Send(ctx context.Context, title, message string) error Send(ctx context.Context, title, message string) error
} }
@@ -63,7 +43,7 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
} }
resp, err := alertClient.Do(req) resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return sanitizeError(err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
@@ -130,24 +110,6 @@ func gotifyPayload(priority string) PayloadFunc {
} }
} }
func opsgeniePayload(priority string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]any{
"message": limitMessage(title, 130),
"description": message,
"source": "uptop",
"priority": priority,
})
}
}
func limitMessage(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
func GetProvider(cfg models.AlertConfig) Provider { func GetProvider(cfg models.AlertConfig) Provider {
switch cfg.Type { switch cfg.Type {
case "discord": case "discord":
@@ -211,20 +173,6 @@ func GetProvider(cfg models.AlertConfig) Provider {
Payload: gotifyPayload(priority), Payload: gotifyPayload(priority),
Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]}, Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]},
} }
case "opsgenie":
priority := "P3"
if p, ok := cfg.Settings["priority"]; ok && p != "" {
priority = p
}
apiURL := "https://api.opsgenie.com/v2/alerts"
if eu, ok := cfg.Settings["eu"]; ok && eu == "true" {
apiURL = "https://api.eu.opsgenie.com/v2/alerts"
}
return &HTTPProvider{
URL: apiURL,
Payload: opsgeniePayload(priority),
Headers: map[string]string{"Authorization": "GenieKey " + cfg.Settings["api_key"]},
}
default: default:
return nil return nil
} }
@@ -246,6 +194,7 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
return ctx.Err() return ctx.Err()
default: default:
} }
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
to := sanitizeHeader(e.To) to := sanitizeHeader(e.To)
from := sanitizeHeader(e.From) from := sanitizeHeader(e.From)
subject := sanitizeHeader(title) subject := sanitizeHeader(title)
@@ -257,67 +206,7 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
"Content-Type: text/plain; charset=utf-8\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" + "\r\n" +
body + "\r\n") body + "\r\n")
return sendMailContext(ctx, e.Host, e.Port, e.User, e.Pass, from, []string{to}, msg) return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
}
// sendMailContext is a ctx-aware replacement for smtp.SendMail.
// smtp.SendMail ignores context entirely — a blackholed SMTP server hangs for
// the OS TCP timeout (minutes). This dials with the context deadline and sets
// connection deadlines so cancellation is respected throughout.
func sendMailContext(ctx context.Context, host, port, user, pass, from string, rcpt []string, msg []byte) error {
addr := host + ":" + port
dialer := net.Dialer{}
conn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf("smtp dial: %w", err)
}
if deadline, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp starttls: %w", err)
}
}
if user != "" || pass != "" {
auth := smtp.PlainAuth("", user, pass, host)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail: %w", err)
}
for _, r := range rcpt {
if err := c.Rcpt(r); err != nil {
return fmt.Errorf("smtp rcpt: %w", err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp data close: %w", err)
}
return c.Quit()
} }
type NtfyProvider struct { type NtfyProvider struct {
@@ -341,7 +230,7 @@ func (n *NtfyProvider) Send(ctx context.Context, title, message string) error {
} }
resp, err := alertClient.Do(req) resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return sanitizeError(err) return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
+1 -218
View File
@@ -1,18 +1,11 @@
package alert package alert
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings"
"testing" "testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
@@ -203,76 +196,8 @@ func TestHTTPProviderGotify(t *testing.T) {
} }
} }
func TestHTTPProviderOpsgenie(t *testing.T) {
var received map[string]any
var authHeader string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader = r.Header.Get("Authorization")
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(202)
}))
defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "test-genie-key",
"priority": "P1",
}})
hp := p.(*HTTPProvider)
hp.URL = srv.URL
if err := p.Send(context.Background(), "Site Down", "mysite.com is unreachable"); err != nil {
t.Fatalf("Send: %v", err)
}
if authHeader != "GenieKey test-genie-key" {
t.Errorf("expected auth 'GenieKey test-genie-key', got '%s'", authHeader)
}
if received["message"] != "Site Down" {
t.Errorf("unexpected message: %v", received["message"])
}
if received["description"] != "mysite.com is unreachable" {
t.Errorf("unexpected description: %v", received["description"])
}
if received["source"] != "uptop" {
t.Errorf("expected source 'uptop', got '%v'", received["source"])
}
if received["priority"] != "P1" {
t.Errorf("expected priority 'P1', got '%v'", received["priority"])
}
}
func TestOpsgenieEUEndpoint(t *testing.T) {
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "key", "eu": "true",
}})
hp := p.(*HTTPProvider)
if hp.URL != "https://api.eu.opsgenie.com/v2/alerts" {
t.Errorf("expected EU URL, got '%s'", hp.URL)
}
}
func TestOpsgenieUSEndpoint(t *testing.T) {
p := GetProvider(models.AlertConfig{Type: "opsgenie", Settings: map[string]string{
"api_key": "key",
}})
hp := p.(*HTTPProvider)
if hp.URL != "https://api.opsgenie.com/v2/alerts" {
t.Errorf("expected US URL, got '%s'", hp.URL)
}
}
func TestLimitMessage(t *testing.T) {
short := "short"
if got := limitMessage(short, 130); got != short {
t.Errorf("expected '%s', got '%s'", short, got)
}
long := string(make([]byte, 200))
if got := limitMessage(long, 130); len(got) != 130 {
t.Errorf("expected length 130, got %d", len(got))
}
}
func TestGetProviderNewTypes(t *testing.T) { func TestGetProviderNewTypes(t *testing.T) {
for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify", "opsgenie"} { for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} {
p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{ p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{
"token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost", "token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost",
}}) }})
@@ -305,145 +230,3 @@ func TestSanitizeHeader(t *testing.T) {
} }
} }
} }
// sanitizeError must strip the credential-bearing URL from a *url.Error while
// keeping the operation and underlying cause.
func TestSanitizeError(t *testing.T) {
urlErr := &url.Error{
Op: "Post",
URL: "https://api.telegram.org/bot123456:SECRET_TOKEN/sendMessage",
Err: errors.New("dial tcp: connection refused"),
}
got := sanitizeError(urlErr).Error()
for _, leak := range []string{"SECRET_TOKEN", "api.telegram.org", "sendMessage", "bot123456"} {
if strings.Contains(got, leak) {
t.Errorf("sanitized error leaked %q: %s", leak, got)
}
}
if !strings.Contains(got, "connection refused") {
t.Errorf("expected underlying cause preserved, got: %s", got)
}
// Non-url errors pass through unchanged.
plain := errors.New("plain failure")
if sanitizeError(plain).Error() != "plain failure" {
t.Errorf("non-url error altered: %s", sanitizeError(plain))
}
if sanitizeError(nil) != nil {
t.Error("nil should stay nil")
}
}
func TestEmailProvider_ContextTimeout(t *testing.T) {
// Listener that accepts but never speaks — simulates a blackholed SMTP server.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
// Hold connection open, never send banner.
go func(c net.Conn) {
time.Sleep(30 * time.Second)
c.Close()
}(conn)
}
}()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
provider := &EmailProvider{
Host: "127.0.0.1", Port: portStr,
From: "test@test.com", To: "dest@test.com",
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
err = provider.Send(ctx, "test", "body")
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected error from stalled SMTP")
}
if elapsed > 2*time.Second {
t.Errorf("Send took %v — context deadline not respected", elapsed)
}
}
func TestSendMailContext_HappyPath(t *testing.T) {
// Minimal fake SMTP server that accepts one message.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
received := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprintf(conn, "220 localhost ESMTP\r\n")
scanner := bufio.NewScanner(conn)
var dataMode bool
var body strings.Builder
for scanner.Scan() {
line := scanner.Text()
if dataMode {
if line == "." {
dataMode = false
fmt.Fprintf(conn, "250 OK\r\n")
continue
}
body.WriteString(line + "\n")
continue
}
switch {
case strings.HasPrefix(line, "EHLO"):
fmt.Fprintf(conn, "250-localhost\r\n250 OK\r\n")
case strings.HasPrefix(line, "MAIL FROM"):
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "RCPT TO"):
fmt.Fprintf(conn, "250 OK\r\n")
case line == "DATA":
fmt.Fprintf(conn, "354 Go ahead\r\n")
dataMode = true
case line == "QUIT":
fmt.Fprintf(conn, "221 Bye\r\n")
received <- body.String()
return
default:
fmt.Fprintf(conn, "250 OK\r\n")
}
}
}()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = sendMailContext(ctx, "127.0.0.1", portStr, "", "", "from@test.com", []string{"to@test.com"}, []byte("Subject: test\r\n\r\nhello"))
if err != nil {
t.Fatalf("sendMailContext: %v", err)
}
select {
case body := <-received:
if !strings.Contains(body, "hello") {
t.Errorf("expected body to contain 'hello', got: %s", body)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for fake SMTP to receive message")
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil) req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
if cfg.SharedKey != "" { if cfg.SharedKey != "" {
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
+66 -7
View File
@@ -12,13 +12,72 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store (minimal, for monitor.NewEngine) ---
type mockStore struct { type mockStore struct {
storetest.BaseMock sites []models.Site
} }
func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { return nil, nil }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
return nil, nil
}
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) {
return nil, nil
}
func (m *mockStore) Close() error { return nil }
// --- Cluster Start Tests --- // --- Cluster Start Tests ---
func TestStart_LeaderMode(t *testing.T) { func TestStart_LeaderMode(t *testing.T) {
@@ -113,7 +172,7 @@ func TestFollowerLoop_SendsSecret(t *testing.T) {
var receivedSecret string var receivedSecret string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock() mu.Lock()
receivedSecret = r.Header.Get("X-Uptop-Secret") receivedSecret = r.Header.Get("X-Upkeep-Secret")
mu.Unlock() mu.Unlock()
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte("OK")) w.Write([]byte("OK"))
@@ -203,7 +262,7 @@ func TestProbeRegister_Failure(t *testing.T) {
func TestProbeFetchAssignments_Success(t *testing.T) { func TestProbeFetchAssignments_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string][]models.Site{ json.NewEncoder(w).Encode(map[string][]models.Site{
"sites": {{SiteConfig: models.SiteConfig{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}}}, "sites": {{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}},
}) })
})) }))
defer srv.Close() defer srv.Close()
@@ -240,8 +299,8 @@ func TestProbeExecuteChecks(t *testing.T) {
defer srv.Close() defer srv.Close()
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Type: "http", URL: srv.URL}}, {ID: 1, Type: "http", URL: srv.URL},
{SiteConfig: models.SiteConfig{ID: 2, Type: "http", URL: srv.URL}}, {ID: 2, Type: "http", URL: srv.URL},
} }
strict := &http.Client{} strict := &http.Client{}
@@ -277,7 +336,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
var sites []models.Site var sites []models.Site
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
sites = append(sites, models.Site{SiteConfig: models.SiteConfig{ID: i + 1, Type: "http", URL: srv.URL}}) sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
} }
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true) results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
+10 -10
View File
@@ -6,7 +6,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log"
"net/http" "net/http"
"net/url" "net/url"
"sync" "sync"
@@ -47,7 +47,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
} }
if err := probeRegister(ctx, apiClient, cfg); err != nil { if err := probeRegister(ctx, apiClient, cfg); err != nil {
slog.Error("probe initial registration failed", "err", err) log.Printf("Probe: initial registration failed: %v (will retry)", err)
} }
for { for {
@@ -59,7 +59,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
sites, err := probeFetchAssignments(ctx, apiClient, cfg) sites, err := probeFetchAssignments(ctx, apiClient, cfg)
if err != nil { if err != nil {
slog.Error("probe failed to fetch assignments", "err", err) log.Printf("Probe: failed to fetch assignments: %v", err)
sleepCtx(ctx, 10*time.Second) sleepCtx(ctx, 10*time.Second)
continue continue
} }
@@ -73,7 +73,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if len(results) > 0 { if len(results) > 0 {
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil { if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
slog.Error("probe failed to report results", "err", err) log.Printf("Probe: failed to report results: %v", err)
} }
} }
@@ -90,7 +90,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
@@ -108,7 +108,7 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -152,12 +152,12 @@ loop:
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
cr := monitor.RunCheck(ctx, s.SiteConfig, strict, insecure, false, allowPrivate) cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
mu.Lock() mu.Lock()
results = append(results, probeResultItem{ results = append(results, probeResultItem{
SiteID: s.ID, SiteID: s.ID,
LatencyNs: cr.LatencyNs, LatencyNs: cr.LatencyNs,
IsUp: cr.Status == string(models.StatusUp), IsUp: cr.Status == "UP",
ErrorReason: cr.ErrorReason, ErrorReason: cr.ErrorReason,
}) })
mu.Unlock() mu.Unlock()
@@ -180,7 +180,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Uptop-Secret", cfg.SharedKey) req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
@@ -189,7 +189,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("results returned %d", resp.StatusCode) return fmt.Errorf("results returned %d", resp.StatusCode)
} }
slog.Info("probe reported check results", "count", len(results)) fmt.Printf("Probe: reported %d check results\n", len(results))
return nil return nil
} }
+21 -23
View File
@@ -1,13 +1,11 @@
package config package config
import ( import (
"context"
"fmt" "fmt"
"reflect"
"strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
"reflect"
"strings"
) )
type ApplyOpts struct { type ApplyOpts struct {
@@ -22,17 +20,17 @@ type Change struct {
Details string Details string
} }
func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Change, error) { func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
if err := Validate(f); err != nil { if err := Validate(f); err != nil {
return nil, err return nil, err
} }
existingAlerts, err := s.GetAllAlerts(ctx) existingAlerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return nil, fmt.Errorf("load alerts: %w", err) return nil, fmt.Errorf("load alerts: %w", err)
} }
existingSites, err := s.GetSites(ctx) existingSites, err := s.GetSites()
if err != nil { if err != nil {
return nil, fmt.Errorf("load sites: %w", err) return nil, fmt.Errorf("load sites: %w", err)
} }
@@ -42,7 +40,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
existingAlertsByName[a.Name] = a existingAlertsByName[a.Name] = a
} }
existingSitesByName := make(map[string]models.SiteConfig, len(existingSites)) existingSitesByName := make(map[string]models.Site, len(existingSites))
for _, s := range existingSites { for _, s := range existingSites {
existingSitesByName[s.Name] = s existingSitesByName[s.Name] = s
} }
@@ -61,7 +59,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type}) changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type})
if !opts.DryRun { if !opts.DryRun {
id, err := s.AddAlertReturningID(ctx, a.Name, a.Type, a.Settings) id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings)
if err != nil { if err != nil {
return changes, fmt.Errorf("create alert %q: %w", a.Name, err) return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
} }
@@ -72,7 +70,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if diff := diffAlert(existing, a); diff != "" { if diff := diffAlert(existing, a); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff})
if !opts.DryRun { if !opts.DryRun {
if err := s.UpdateAlert(ctx, existing.ID, a.Name, a.Type, a.Settings); err != nil { if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil {
return changes, fmt.Errorf("update alert %q: %w", a.Name, err) return changes, fmt.Errorf("update alert %q: %w", a.Name, err)
} }
} }
@@ -104,7 +102,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"}) changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"})
if !opts.DryRun { if !opts.DryRun {
id, err := s.AddSiteReturningID(ctx, site) id, err := s.AddSiteReturningID(site)
if err != nil { if err != nil {
return changes, fmt.Errorf("create group %q: %w", g.Name, err) return changes, fmt.Errorf("create group %q: %w", g.Name, err)
} }
@@ -116,7 +114,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
if diff := diffSite(normalizeSite(existing), site); diff != "" { if diff := diffSite(normalizeSite(existing), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff})
if !opts.DryRun { if !opts.DryRun {
if err := s.UpdateSite(ctx, site); err != nil { if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update group %q: %w", g.Name, err) return changes, fmt.Errorf("update group %q: %w", g.Name, err)
} }
} }
@@ -127,7 +125,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
for _, g := range groups { for _, g := range groups {
parentID := groupMap[g.Name] parentID := groupMap[g.Name]
for _, child := range g.Monitors { for _, child := range g.Monitors {
c, err := applyMonitor(ctx, s, child, alertMap, existingSitesByName, parentID, opts.DryRun) c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
if err != nil { if err != nil {
return changes, err return changes, err
} }
@@ -136,7 +134,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
} }
for _, m := range topLevel { for _, m := range topLevel {
c, err := applyMonitor(ctx, s, m, alertMap, existingSitesByName, 0, opts.DryRun) c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun)
if err != nil { if err != nil {
return changes, err return changes, err
} }
@@ -157,7 +155,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
childDeletes = append(childDeletes, c) childDeletes = append(childDeletes, c)
} }
if !opts.DryRun { if !opts.DryRun {
if err := s.DeleteSite(ctx, es.ID); err != nil { if err := s.DeleteSite(es.ID); err != nil {
return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err) return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err)
} }
} }
@@ -171,7 +169,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
} }
changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type}) changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type})
if !opts.DryRun { if !opts.DryRun {
if err := s.DeleteAlert(ctx, ea.ID); err != nil { if err := s.DeleteAlert(ea.ID); err != nil {
return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err) return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err)
} }
} }
@@ -181,7 +179,7 @@ func Apply(ctx context.Context, s store.Store, f *File, opts ApplyOpts) ([]Chang
return changes, nil return changes, nil
} }
func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.SiteConfig, parentID int, dryRun bool) ([]Change, error) { func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) {
alertID, err := resolveAlertID(alertMap, m.Alert) alertID, err := resolveAlertID(alertMap, m.Alert)
if err != nil { if err != nil {
return nil, fmt.Errorf("monitor %q: %w", m.Name, err) return nil, fmt.Errorf("monitor %q: %w", m.Name, err)
@@ -193,7 +191,7 @@ func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[st
if !exists { if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type}) changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type})
if !dryRun { if !dryRun {
if _, err := s.AddSiteReturningID(ctx, site); err != nil { if _, err := s.AddSiteReturningID(site); err != nil {
return changes, fmt.Errorf("create monitor %q: %w", m.Name, err) return changes, fmt.Errorf("create monitor %q: %w", m.Name, err)
} }
} }
@@ -202,7 +200,7 @@ func applyMonitor(ctx context.Context, s store.Store, m Monitor, alertMap map[st
if diff := diffSite(normalizeSite(ex), site); diff != "" { if diff := diffSite(normalizeSite(ex), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff}) changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff})
if !dryRun { if !dryRun {
if err := s.UpdateSite(ctx, site); err != nil { if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update monitor %q: %w", m.Name, err) return changes, fmt.Errorf("update monitor %q: %w", m.Name, err)
} }
} }
@@ -222,8 +220,8 @@ func resolveAlertID(alertMap map[string]int, name string) (int, error) {
return id, nil return id, nil
} }
func monitorToSite(m Monitor, alertID, parentID int) models.SiteConfig { func monitorToSite(m Monitor, alertID, parentID int) models.Site {
s := models.SiteConfig{ s := models.Site{
Name: m.Name, Name: m.Name,
Type: m.Type, Type: m.Type,
URL: m.URL, URL: m.URL,
@@ -269,7 +267,7 @@ func collectMonitorNames(monitors []Monitor, names map[string]bool) {
} }
} }
func normalizeSite(s models.SiteConfig) models.SiteConfig { func normalizeSite(s models.Site) models.Site {
if s.Method == "" { if s.Method == "" {
s.Method = "GET" s.Method = "GET"
} }
@@ -293,7 +291,7 @@ func diffAlert(existing models.AlertConfig, desired Alert) string {
return strings.Join(diffs, ", ") return strings.Join(diffs, ", ")
} }
func diffSite(existing, desired models.SiteConfig) string { func diffSite(existing, desired models.Site) string {
var diffs []string var diffs []string
if existing.URL != desired.URL { if existing.URL != desired.URL {
diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL)) diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL))
+28 -30
View File
@@ -1,12 +1,10 @@
package config package config
import ( import (
"context"
"strings"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
"strings"
"testing"
) )
func newTestStore(t *testing.T) store.Store { func newTestStore(t *testing.T) store.Store {
@@ -15,7 +13,7 @@ func newTestStore(t *testing.T) store.Store {
if err != nil { if err != nil {
t.Fatalf("NewSQLiteStore: %v", err) t.Fatalf("NewSQLiteStore: %v", err)
} }
if err := s.Init(context.Background()); err != nil { if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err) t.Fatalf("Init: %v", err)
} }
return s return s
@@ -33,7 +31,7 @@ func TestApplyCreateFromScratch(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -48,12 +46,12 @@ func TestApplyCreateFromScratch(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", creates) t.Fatalf("expected 3 creates, got %d", creates)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 2 { if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites)) t.Fatalf("expected 2 sites, got %d", len(sites))
} }
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
if len(alerts) != 1 { if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts)) t.Fatalf("expected 1 alert, got %d", len(alerts))
} }
@@ -70,11 +68,11 @@ func TestApplyIdempotent(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err) t.Fatalf("first Apply: %v", err)
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("second Apply: %v", err) t.Fatalf("second Apply: %v", err)
} }
@@ -92,12 +90,12 @@ func TestApplyUpdate(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err) t.Fatalf("first Apply: %v", err)
} }
f.Monitors[0].Interval = 60 f.Monitors[0].Interval = 60
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("second Apply: %v", err) t.Fatalf("second Apply: %v", err)
} }
@@ -106,7 +104,7 @@ func TestApplyUpdate(t *testing.T) {
t.Fatalf("expected 1 update, got %+v", changes) t.Fatalf("expected 1 update, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if sites[0].Interval != 60 { if sites[0].Interval != 60 {
t.Fatalf("expected interval 60, got %d", sites[0].Interval) t.Fatalf("expected interval 60, got %d", sites[0].Interval)
} }
@@ -114,8 +112,8 @@ func TestApplyUpdate(t *testing.T) {
func TestApplyPrune(t *testing.T) { func TestApplyPrune(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddSite(context.Background(), models.SiteConfig{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f := &File{ f := &File{
Monitors: []Monitor{ Monitors: []Monitor{
@@ -123,7 +121,7 @@ func TestApplyPrune(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{Prune: true}) changes, err := Apply(s, f, ApplyOpts{Prune: true})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -138,7 +136,7 @@ func TestApplyPrune(t *testing.T) {
t.Fatalf("expected 1 delete, got %d", deleteCount) t.Fatalf("expected 1 delete, got %d", deleteCount)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 1 || sites[0].Name != "Keep" { if len(sites) != 1 || sites[0].Name != "Keep" {
t.Fatalf("expected only 'Keep', got %+v", sites) t.Fatalf("expected only 'Keep', got %+v", sites)
} }
@@ -152,7 +150,7 @@ func TestApplyDryRun(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{DryRun: true}) changes, err := Apply(s, f, ApplyOpts{DryRun: true})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -161,7 +159,7 @@ func TestApplyDryRun(t *testing.T) {
t.Fatalf("expected 1 create in dry-run, got %+v", changes) t.Fatalf("expected 1 create in dry-run, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if len(sites) != 0 { if len(sites) != 0 {
t.Fatalf("expected 0 sites after dry-run, got %d", len(sites)) t.Fatalf("expected 0 sites after dry-run, got %d", len(sites))
} }
@@ -181,7 +179,7 @@ func TestApplyGroupHierarchy(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -190,8 +188,8 @@ func TestApplyGroupHierarchy(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", len(changes)) t.Fatalf("expected 3 creates, got %d", len(changes))
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
var group models.SiteConfig var group models.Site
for _, s := range sites { for _, s := range sites {
if s.Type == "group" { if s.Type == "group" {
group = s group = s
@@ -225,12 +223,12 @@ func TestApplyAlertReference(t *testing.T) {
}, },
} }
if _, err := Apply(context.Background(), s, f, ApplyOpts{}); err != nil { if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
if sites[0].AlertID != alerts[0].ID { if sites[0].AlertID != alerts[0].ID {
t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID) t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID)
@@ -245,7 +243,7 @@ func TestApplyInvalidAlertRef(t *testing.T) {
}, },
} }
_, err := Apply(context.Background(), s, f, ApplyOpts{}) _, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "not found") { if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected alert not found error, got %v", err) t.Fatalf("expected alert not found error, got %v", err)
} }
@@ -260,7 +258,7 @@ func TestApplyDuplicateNames(t *testing.T) {
}, },
} }
_, err := Apply(context.Background(), s, f, ApplyOpts{}) _, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "duplicate") { if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate error, got %v", err) t.Fatalf("expected duplicate error, got %v", err)
} }
@@ -268,7 +266,7 @@ func TestApplyDuplicateNames(t *testing.T) {
func TestApplyExistingAlertReference(t *testing.T) { func TestApplyExistingAlertReference(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddAlert(context.Background(), "Existing", "webhook", map[string]string{"url": "https://example.com"}) s.AddAlert("Existing", "webhook", map[string]string{"url": "https://example.com"})
f := &File{ f := &File{
Monitors: []Monitor{ Monitors: []Monitor{
@@ -276,7 +274,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
}, },
} }
changes, err := Apply(context.Background(), s, f, ApplyOpts{}) changes, err := Apply(s, f, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -285,7 +283,7 @@ func TestApplyExistingAlertReference(t *testing.T) {
t.Fatalf("expected 1 create, got %+v", changes) t.Fatalf("expected 1 create, got %+v", changes)
} }
sites, _ := s.GetSites(context.Background()) sites, _ := s.GetSites()
if sites[0].AlertID == 0 { if sites[0].AlertID == 0 {
t.Fatal("expected non-zero alert_id for existing alert reference") t.Fatal("expected non-zero alert_id for existing alert reference")
} }
+7 -8
View File
@@ -1,7 +1,6 @@
package config package config
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"sort" "sort"
@@ -12,13 +11,13 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
func Export(ctx context.Context, s store.Store) (*File, error) { func Export(s store.Store) (*File, error) {
dbAlerts, err := s.GetAllAlerts(ctx) dbAlerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return nil, fmt.Errorf("load alerts: %w", err) return nil, fmt.Errorf("load alerts: %w", err)
} }
dbSites, err := s.GetSites(ctx) dbSites, err := s.GetSites()
if err != nil { if err != nil {
return nil, fmt.Errorf("load sites: %w", err) return nil, fmt.Errorf("load sites: %w", err)
} }
@@ -34,9 +33,9 @@ func Export(ctx context.Context, s store.Store) (*File, error) {
}) })
} }
groups := make(map[int]models.SiteConfig) groups := make(map[int]models.Site)
children := make(map[int][]models.SiteConfig) children := make(map[int][]models.Site)
var topLevel []models.SiteConfig var topLevel []models.Site
for _, s := range dbSites { for _, s := range dbSites {
switch { switch {
@@ -76,7 +75,7 @@ func Export(ctx context.Context, s store.Store) (*File, error) {
return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil
} }
func siteToMonitor(s models.SiteConfig, alertIDToName map[int]string) Monitor { func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor {
m := Monitor{ m := Monitor{
Name: s.Name, Name: s.Name,
Type: s.Type, Type: s.Type,
+19 -21
View File
@@ -1,15 +1,13 @@
package config package config
import ( import (
"context"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"testing"
) )
func TestExportEmpty(t *testing.T) { func TestExportEmpty(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -20,11 +18,11 @@ func TestExportEmpty(t *testing.T) {
func TestExportAlertNames(t *testing.T) { func TestExportAlertNames(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s.GetAllAlerts(context.Background()) alerts, _ := s.GetAllAlerts()
s.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -39,11 +37,11 @@ func TestExportAlertNames(t *testing.T) {
func TestExportGroupHierarchy(t *testing.T) { func TestExportGroupHierarchy(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
groupID, _ := s.AddSiteReturningID(context.Background(), models.SiteConfig{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(context.Background(), models.SiteConfig{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s.AddSite(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -72,12 +70,12 @@ func TestExportGroupHierarchy(t *testing.T) {
func TestExportOmitsDefaults(t *testing.T) { func TestExportOmitsDefaults(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
s.AddSite(context.Background(), models.SiteConfig{ s.AddSite(models.Site{
Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, Name: "Web", URL: "https://example.com", Type: "http", Interval: 30,
Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7,
}) })
f, err := Export(context.Background(), s) f, err := Export(s)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
@@ -96,18 +94,18 @@ func TestExportOmitsDefaults(t *testing.T) {
func TestExportRoundTrip(t *testing.T) { func TestExportRoundTrip(t *testing.T) {
s1 := newTestStore(t) s1 := newTestStore(t)
s1.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com"}) s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s1.GetAllAlerts(context.Background()) alerts, _ := s1.GetAllAlerts()
s1.AddSite(context.Background(), models.SiteConfig{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s1.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s1.AddSite(context.Background(), models.SiteConfig{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"}) s1.AddSite(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
exported, err := Export(context.Background(), s1) exported, err := Export(s1)
if err != nil { if err != nil {
t.Fatalf("Export: %v", err) t.Fatalf("Export: %v", err)
} }
s2 := newTestStore(t) s2 := newTestStore(t)
changes, err := Apply(context.Background(), s2, exported, ApplyOpts{}) changes, err := Apply(s2, exported, ApplyOpts{})
if err != nil { if err != nil {
t.Fatalf("Apply: %v", err) t.Fatalf("Apply: %v", err)
} }
@@ -122,7 +120,7 @@ func TestExportRoundTrip(t *testing.T) {
t.Fatalf("expected 3 creates, got %d", creates) t.Fatalf("expected 3 creates, got %d", creates)
} }
reexported, err := Export(context.Background(), s2) reexported, err := Export(s2)
if err != nil { if err != nil {
t.Fatalf("re-Export: %v", err) t.Fatalf("re-Export: %v", err)
} }
+4 -15
View File
@@ -1,14 +1,11 @@
package importer package importer
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"os" "os"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type KumaBackup struct { type KumaBackup struct {
@@ -83,7 +80,7 @@ func ConvertKuma(kb *KumaBackup) models.Backup {
} }
} }
var sites []models.SiteConfig var sites []models.Site
for _, m := range kb.MonitorList { for _, m := range kb.MonitorList {
site := convertKumaMonitor(m, kumaToUpkeepAlert) site := convertKumaMonitor(m, kumaToUpkeepAlert)
sites = append(sites, site) sites = append(sites, site)
@@ -135,8 +132,8 @@ func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConf
return result return result
} }
func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig { func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
site := models.SiteConfig{ site := models.Site{
ID: m.ID, ID: m.ID,
Name: m.Name, Name: m.Name,
Description: m.Description, Description: m.Description,
@@ -158,18 +155,10 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.SiteConfig {
site.DNSResolveType = m.DNSResolveType site.DNSResolveType = m.DNSResolveType
site.DNSServer = m.DNSResolveServer site.DNSServer = m.DNSResolveServer
site.Paused = !m.Active
switch m.Type { switch m.Type {
case "http": case "http":
site.URL = m.URL site.URL = m.URL
site.CheckSSL = m.ExpiryNotif site.CheckSSL = m.ExpiryNotif
case "push":
site.Type = "push"
b := make([]byte, 16)
if _, err := rand.Read(b); err == nil {
site.Token = hex.EncodeToString(b)
}
case "ping": case "ping":
if m.Hostname != "" { if m.Hostname != "" {
site.Hostname = m.Hostname site.Hostname = m.Hostname
-210
View File
@@ -1,210 +0,0 @@
package importer
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTemp(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "backup.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadKumaFileMissingFile(t *testing.T) {
_, err := LoadKumaFile(filepath.Join(t.TempDir(), "nope.json"))
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestLoadKumaFileMalformedInput(t *testing.T) {
cases := []struct {
name string
body string
}{
{"empty file", ""},
{"truncated JSON", `{"version": "1.23", "monitorList": [`},
{"not JSON", "definitely not json"},
{"wrong root type", `[1, 2, 3]`},
{"monitorList wrong type", `{"monitorList": {"a": 1}}`},
{"monitor field wrong type", `{"monitorList": [{"id": "not-an-int"}]}`},
{"notificationList wrong type", `{"notificationList": "oops"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := LoadKumaFile(writeTemp(t, tc.body))
if err == nil {
t.Fatalf("expected parse error for %s", tc.name)
}
if !strings.Contains(err.Error(), "parse JSON") {
t.Fatalf("expected wrapped parse error, got: %v", err)
}
})
}
}
func TestLoadKumaFileNullLists(t *testing.T) {
kb, err := LoadKumaFile(writeTemp(t, `{"version": "1.23", "monitorList": null, "notificationList": null}`))
if err != nil {
t.Fatal(err)
}
backup := ConvertKuma(kb)
if len(backup.Sites) != 0 || len(backup.Alerts) != 0 {
t.Fatalf("expected empty backup, got %d sites %d alerts", len(backup.Sites), len(backup.Alerts))
}
}
func TestConvertKumaSkipsMalformedNotificationConfig(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 1, Name: "broken", Config: "{not json"},
{ID: 2, Name: "good", Config: `{"type": "discord", "ntfyserverurl": "https://example.com/hook"}`},
},
MonitorList: []KumaMonitor{
{ID: 10, Name: "site", Type: "http", URL: "https://example.com", NotificationIDs: map[string]bool{"1": true}},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 {
t.Fatalf("expected broken notification skipped, got %d alerts", len(backup.Alerts))
}
if backup.Alerts[0].Type != "discord" {
t.Fatalf("expected discord alert, got %q", backup.Alerts[0].Type)
}
if backup.Sites[0].AlertID != 0 {
t.Fatalf("site referencing skipped notification should keep AlertID 0, got %d", backup.Sites[0].AlertID)
}
}
func TestConvertKumaNtfyNotification(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 3, Name: "ntfy", Config: `{
"type": "ntfy",
"ntfyserverurl": "https://ntfy.example.com/",
"ntfytopic": "uptime",
"ntfyPriority": 4,
"ntfyAuthenticationMethod": "usernamePassword",
"ntfyusername": "u",
"ntfypassword": "p"
}`},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(backup.Alerts))
}
a := backup.Alerts[0]
if a.Type != "ntfy" {
t.Fatalf("expected ntfy, got %q", a.Type)
}
if a.Settings["url"] != "https://ntfy.example.com" {
t.Fatalf("expected trailing slash trimmed, got %q", a.Settings["url"])
}
if a.Settings["topic"] != "uptime" || a.Settings["priority"] != "4" {
t.Fatalf("unexpected settings: %v", a.Settings)
}
if a.Settings["username"] != "u" || a.Settings["password"] != "p" {
t.Fatalf("expected credentials mapped, got %v", a.Settings)
}
}
func TestConvertKumaUnknownNotificationFallsBackToWebhook(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 4, Name: "matrix", Config: `{"type": "matrix", "ntfyserverurl": "https://example.com/hook"}`},
},
}
backup := ConvertKuma(kb)
if len(backup.Alerts) != 1 || backup.Alerts[0].Type != "webhook" {
t.Fatalf("expected webhook fallback, got %+v", backup.Alerts)
}
}
func TestConvertKumaHTTPMonitor(t *testing.T) {
kb := &KumaBackup{
NotificationList: []KumaNotifEntry{
{ID: 1, Name: "hook", Config: `{"type": "slack", "ntfyserverurl": "https://example.com/hook"}`},
},
MonitorList: []KumaMonitor{{
ID: 7,
Name: "web",
Type: "http",
URL: "https://example.com",
Interval: 60,
Timeout: 30,
MaxRetries: 2,
Method: "GET",
AcceptedCodes: []string{"200", "301"},
IgnoreTLS: true,
ExpiryNotif: true,
Active: false,
NotificationIDs: map[string]bool{"1": true},
}},
}
backup := ConvertKuma(kb)
if len(backup.Sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(backup.Sites))
}
s := backup.Sites[0]
if s.URL != "https://example.com" || !s.CheckSSL || !s.IgnoreTLS {
t.Fatalf("http fields not mapped: %+v", s)
}
if !s.Paused {
t.Fatal("inactive monitor should import paused")
}
if s.AcceptedCodes != "200,301" {
t.Fatalf("expected joined accepted codes, got %q", s.AcceptedCodes)
}
if s.AlertID != 1 {
t.Fatalf("expected alert mapped, got %d", s.AlertID)
}
}
func TestConvertKumaPushMonitorGetsToken(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{{ID: 1, Name: "push", Type: "push", Active: true}},
}
backup := ConvertKuma(kb)
token := backup.Sites[0].Token
if len(token) != 32 {
t.Fatalf("expected 32-char hex token, got %q", token)
}
}
func TestConvertKumaNonNumericNotificationID(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{{
ID: 1,
Name: "site",
Type: "http",
NotificationIDs: map[string]bool{"abc": true},
}},
}
backup := ConvertKuma(kb)
if backup.Sites[0].AlertID != 0 {
t.Fatalf("non-numeric notification ID should not map, got %d", backup.Sites[0].AlertID)
}
}
func TestConvertKumaGroupAndChildren(t *testing.T) {
kb := &KumaBackup{
MonitorList: []KumaMonitor{
{ID: 1, Name: "grp", Type: "group", Active: true},
{ID: 2, Name: "ping", Type: "ping", Hostname: "10.0.0.1", Parent: 1, Active: true},
},
}
backup := ConvertKuma(kb)
if backup.Sites[0].Type != "group" {
t.Fatalf("expected group type, got %q", backup.Sites[0].Type)
}
if backup.Sites[1].ParentID != 1 || backup.Sites[1].Hostname != "10.0.0.1" {
t.Fatalf("child not mapped: %+v", backup.Sites[1])
}
}
+3 -4
View File
@@ -2,12 +2,11 @@ package metrics
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
) )
func Handler(eng *monitor.Engine) http.HandlerFunc { func Handler(eng *monitor.Engine) http.HandlerFunc {
@@ -20,7 +19,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == models.StatusUp { if s.Status == "UP" {
val = 1 val = 1
} }
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
+60 -6
View File
@@ -10,21 +10,75 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
type mockStore struct { type mockStore struct {
storetest.BaseMock sites []models.Site
sites []models.SiteConfig
} }
func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { func (m *mockStore) Init() error { return nil }
return m.sites, nil func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
} }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
return nil, nil
}
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) {
return nil, nil
}
func (m *mockStore) Close() error { return nil }
func TestMetricsHandler(t *testing.T) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
sites: []models.SiteConfig{ sites: []models.Site{
{ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30}, {ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30},
{ID: 2, Name: "DNS Check", Type: "dns", Interval: 60}, {ID: 2, Name: "DNS Check", Type: "dns", Interval: 60},
}, },
+3 -10
View File
@@ -2,7 +2,7 @@ package models
import "time" import "time"
type SiteConfig struct { type Site struct {
ID int ID int
Name string Name string
URL string URL string
@@ -26,11 +26,9 @@ type SiteConfig struct {
IgnoreTLS bool IgnoreTLS bool
Paused bool Paused bool
Regions string Regions string
}
type SiteState struct {
FailureCount int FailureCount int
Status Status Status string
StatusCode int StatusCode int
Latency time.Duration Latency time.Duration
CertExpiry time.Time CertExpiry time.Time
@@ -42,11 +40,6 @@ type SiteState struct {
LastSuccessAt time.Time LastSuccessAt time.Time
} }
type Site struct {
SiteConfig
SiteState
}
type StateChange struct { type StateChange struct {
ID int ID int
SiteID int SiteID int
@@ -110,7 +103,7 @@ type MaintenanceWindow struct {
} }
type Backup struct { type Backup struct {
Sites []SiteConfig `json:"sites"` Sites []Site `json:"sites"`
Alerts []AlertConfig `json:"alerts"` Alerts []AlertConfig `json:"alerts"`
Users []User `json:"users"` Users []User `json:"users"`
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"` MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
-36
View File
@@ -1,36 +0,0 @@
package models
// safeAlertSettingKeys lists, per provider type, the alert settings that are
// NOT secret and may be shown or exported in the clear. Everything else is
// redacted. Providers absent from this map (discord, slack, webhook, pushover)
// carry their secret in a field a denylist would miss — the webhook URL, the
// pushover token/user — so all of their settings are redacted.
var safeAlertSettingKeys = map[string]map[string]bool{
"email": {"host": true, "port": true, "to": true, "from": true},
"ntfy": {"topic": true, "priority": true},
"telegram": {"chat_id": true},
"pagerduty": {"severity": true},
"gotify": {"priority": true},
"opsgenie": {"priority": true, "eu": true},
}
// RedactAlertSettings keeps only the known-safe keys for the alert type and
// redacts everything else. An allowlist fails safe: an unknown or newly added
// setting is redacted by default instead of leaking. Shared by the backup
// export path and the TUI alert detail panel so both render through the same
// policy.
func RedactAlertSettings(alertType string, settings map[string]string) map[string]string {
safe := safeAlertSettingKeys[alertType]
redacted := make(map[string]string, len(settings))
for k, v := range settings {
switch {
case v == "":
redacted[k] = ""
case safe[k]:
redacted[k] = v
default:
redacted[k] = "***REDACTED***"
}
}
return redacted
}
-18
View File
@@ -1,18 +0,0 @@
package models
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
StatusPending Status = "PENDING"
StatusLate Status = "LATE"
StatusStale Status = "STALE"
StatusSSLExp Status = "SSL EXP"
)
func (s Status) IsBroken() bool {
return s == StatusDown || s == StatusSSLExp
}
func (s Status) String() string { return string(s) }
+40 -80
View File
@@ -3,7 +3,6 @@ package monitor
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -16,16 +15,6 @@ import (
probing "github.com/prometheus-community/pro-bing" probing "github.com/prometheus-community/pro-bing"
) )
const (
maxErrorLength = 256
defaultAcceptedCodes = "200-299"
defaultHTTPStatusMin = 200
defaultHTTPStatusMax = 300
defaultTimeout = 5 * time.Second
defaultDNSServer = "1.1.1.1"
defaultDNSPort = "53"
)
type CheckResult struct { type CheckResult struct {
SiteID int SiteID int
Status string // "UP", "DOWN", "SSL EXP" Status string // "UP", "DOWN", "SSL EXP"
@@ -36,57 +25,52 @@ type CheckResult struct {
ErrorReason string ErrorReason string
} }
func RunCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure, allowPrivate bool) CheckResult { func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
// Resolve + validate once for non-HTTP types to prevent DNS-rebind TOCTOU: private := len(allowPrivate) > 0 && allowPrivate[0]
// a second resolve in the check function could return a different (private) IP.
// HTTP is safe — SafeDialContext resolves and validates at dial time. if site.Type != "http" && site.Type != "dns" && !private {
var pinnedIP net.IP
if site.Type != "http" && site.Type != "dns" && !allowPrivate {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
} }
if host != "" { if host != "" {
ips, err := net.LookupIP(host) if ips, err := net.LookupIP(host); err == nil {
if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "resolve failed: " + err.Error()}
}
for _, ip := range ips { for _, ip := range ips {
if isPrivateIP(ip) { if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "target resolves to private IP"} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"}
}
} }
} }
pinnedIP = ips[0]
} }
} }
switch site.Type { switch site.Type {
case "http": case "http":
return runHTTPCheck(ctx, site, strict, insecure, globalInsecure) return runHTTPCheck(site, strict, insecure, globalInsecure)
case "ping": case "ping":
return runPingCheck(ctx, site, pinnedIP) return runPingCheck(site)
case "port": case "port":
return runPortCheck(ctx, site, pinnedIP) return runPortCheck(site)
case "dns": case "dns":
return runDNSCheck(ctx, site, allowPrivate) return runDNSCheck(site)
default: default:
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "unsupported monitor type: " + site.Type} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type}
} }
} }
func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure *http.Client, globalInsecure bool) CheckResult { func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
method := site.Method method := site.Method
if method == "" { if method == "" {
method = "GET" method = "GET"
} }
timeout := siteTimeout(site) timeout := siteTimeout(site)
ctx, cancel := context.WithTimeout(ctx, timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil) req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "invalid request: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()}
} }
client := strict client := strict
@@ -100,26 +84,23 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
result := CheckResult{ result := CheckResult{
SiteID: site.ID, SiteID: site.ID,
Status: string(models.StatusUp), Status: "UP",
LatencyNs: latency.Nanoseconds(), LatencyNs: latency.Nanoseconds(),
} }
if err != nil { if err != nil {
result.Status = string(models.StatusDown) result.Status = "DOWN"
result.ErrorReason = truncateError(err.Error(), maxErrorLength) result.ErrorReason = truncateError(err.Error(), 256)
return result return result
} }
defer func() { defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
result.StatusCode = resp.StatusCode result.StatusCode = resp.StatusCode
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) { if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
result.Status = string(models.StatusDown) result.Status = "DOWN"
expected := site.AcceptedCodes expected := site.AcceptedCodes
if expected == "" { if expected == "" {
expected = defaultAcceptedCodes expected = "200-299"
} }
result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected) result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected)
} }
@@ -129,7 +110,7 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
cert := resp.TLS.PeerCertificates[0] cert := resp.TLS.PeerCertificates[0]
result.CertExpiry = cert.NotAfter result.CertExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) { if time.Now().After(cert.NotAfter) {
result.Status = string(models.StatusSSLExp) result.Status = "SSL EXP"
result.ErrorReason = "SSL certificate expired" result.ErrorReason = "SSL certificate expired"
} }
} }
@@ -137,7 +118,7 @@ func runHTTPCheck(ctx context.Context, site models.SiteConfig, strict, insecure
return result return result
} }
func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult { func runPingCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
@@ -145,10 +126,7 @@ func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
pinger, err := probing.NewPinger(host) pinger, err := probing.NewPinger(host)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "ping setup: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()}
}
if pinnedIP != nil {
pinger.SetIPAddr(&net.IPAddr{IP: pinnedIP})
} }
pinger.Count = 1 pinger.Count = 1
pinger.Timeout = siteTimeout(site) pinger.Timeout = siteTimeout(site)
@@ -159,24 +137,21 @@ func runPingCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
} }
if pinger.Statistics().PacketsRecv == 0 { if pinger.Statistics().PacketsRecv == 0 {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
} }
stats := pinger.Statistics() stats := pinger.Statistics()
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: stats.AvgRtt.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()}
} }
func runPortCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) CheckResult { func runPortCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
} }
if pinnedIP != nil {
host = pinnedIP.String()
}
addr := net.JoinHostPort(host, strconv.Itoa(site.Port)) addr := net.JoinHostPort(host, strconv.Itoa(site.Port))
timeout := siteTimeout(site) timeout := siteTimeout(site)
@@ -185,13 +160,13 @@ func runPortCheck(_ context.Context, site models.SiteConfig, pinnedIP net.IP) Ch
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), maxErrorLength)} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), 256)}
} }
_ = conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) CheckResult { func runDNSCheck(site models.Site) CheckResult {
host := site.Hostname host := site.Hostname
if host == "" { if host == "" {
host = site.URL host = site.URL
@@ -199,26 +174,11 @@ func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) C
server := site.DNSServer server := site.DNSServer
if server == "" { if server == "" {
server = defaultDNSServer server = "1.1.1.1"
} }
serverHost, serverPort, err := net.SplitHostPort(server) if _, _, err := net.SplitHostPort(server); err != nil {
if err != nil { server = net.JoinHostPort(server, "53")
serverHost = server
serverPort = defaultDNSPort
} }
if !allowPrivate {
if serverPort != defaultDNSPort {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "DNS server port must be 53"}
}
if ips, err := net.LookupIP(serverHost); err == nil {
for _, ip := range ips {
if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), ErrorReason: "DNS server resolves to private address"}
}
}
}
}
server = net.JoinHostPort(serverHost, serverPort)
qtype := dns.TypeA qtype := dns.TypeA
switch site.DNSResolveType { switch site.DNSResolveType {
@@ -251,24 +211,24 @@ func runDNSCheck(_ context.Context, site models.SiteConfig, allowPrivate bool) C
latency := time.Since(start) latency := time.Since(start)
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
} }
if r.Rcode != dns.RcodeSuccess { if r.Rcode != dns.RcodeSuccess {
return CheckResult{SiteID: site.ID, Status: string(models.StatusDown), StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]} return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]}
} }
return CheckResult{SiteID: site.ID, Status: string(models.StatusUp), LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
func siteTimeout(site models.SiteConfig) time.Duration { func siteTimeout(site models.Site) time.Duration {
if site.Timeout > 0 { if site.Timeout > 0 {
return time.Duration(site.Timeout) * time.Second return time.Duration(site.Timeout) * time.Second
} }
return defaultTimeout return 5 * time.Second
} }
func isCodeAccepted(code int, accepted string) bool { func isCodeAccepted(code int, accepted string) bool {
if accepted == "" { if accepted == "" {
return code >= defaultHTTPStatusMin && code < defaultHTTPStatusMax return code >= 200 && code < 300
} }
for _, part := range strings.Split(accepted, ",") { for _, part := range strings.Split(accepted, ",") {
part = strings.TrimSpace(part) part = strings.TrimSpace(part)
+22 -60
View File
@@ -1,7 +1,6 @@
package monitor package monitor
import ( import (
"context"
"crypto/tls" "crypto/tls"
"net" "net"
"net/http" "net/http"
@@ -19,8 +18,8 @@ func TestRunCheck_HTTP_Success(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -39,8 +38,8 @@ func TestRunCheck_HTTP_ServerError(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL} site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status) t.Errorf("expected DOWN, got %s", result.Status)
@@ -60,8 +59,8 @@ func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) {
return http.ErrUseLastResponse return http.ErrUseLastResponse
}} }}
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"} site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"}
result := RunCheck(context.Background(), site, client, client, false, false) result := RunCheck(site, client, client, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP with accepted 200-399, got %s", result.Status) t.Errorf("expected UP with accepted 200-399, got %s", result.Status)
@@ -76,8 +75,8 @@ func TestRunCheck_HTTP_MethodRespected(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"} site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"}
RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if receivedMethod != "HEAD" { if receivedMethod != "HEAD" {
t.Errorf("expected HEAD, got %s", receivedMethod) t.Errorf("expected HEAD, got %s", receivedMethod)
@@ -91,8 +90,8 @@ func TestRunCheck_HTTP_Timeout(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, Timeout: 1} site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1}
result := RunCheck(context.Background(), site, http.DefaultClient, http.DefaultClient, false, false) result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN on timeout, got %s", result.Status) t.Errorf("expected DOWN on timeout, got %s", result.Status)
@@ -109,8 +108,8 @@ func TestRunCheck_HTTP_SSLFields(t *testing.T) {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
} }
site := models.SiteConfig{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true} site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true}
result := RunCheck(context.Background(), site, http.DefaultClient, insecureClient, false, false) result := RunCheck(site, http.DefaultClient, insecureClient, false)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -133,8 +132,8 @@ func TestRunCheck_Port_Open(t *testing.T) {
_, portStr, _ := net.SplitHostPort(ln.Addr().String()) _, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr) port, _ := strconv.Atoi(portStr)
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, true) result := RunCheck(site, nil, nil, false, true)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -153,51 +152,14 @@ func TestRunCheck_Port_Closed(t *testing.T) {
port, _ := strconv.Atoi(portStr) port, _ := strconv.Atoi(portStr)
ln.Close() ln.Close()
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
result := RunCheck(context.Background(), site, nil, nil, false, true) result := RunCheck(site, nil, nil, false, true)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status) t.Errorf("expected DOWN, got %s", result.Status)
} }
} }
func TestRunPortCheck_UsesPinnedIP(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
// 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, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := runPortCheck(context.Background(), site, nil)
if result.Status != "UP" {
t.Errorf("expected UP with nil pinnedIP fallback, got %s: %s", result.Status, result.ErrorReason)
}
}
func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) { func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0") ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
@@ -208,8 +170,8 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
_, portStr, _ := net.SplitHostPort(ln.Addr().String()) _, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr) port, _ := strconv.Atoi(portStr)
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(context.Background(), site, nil, nil, false, false) result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN when private targets blocked, got %s", result.Status) t.Errorf("expected DOWN when private targets blocked, got %s", result.Status)
@@ -217,8 +179,8 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
} }
func TestRunCheck_UnknownType(t *testing.T) { func TestRunCheck_UnknownType(t *testing.T) {
site := models.SiteConfig{ID: 1, Type: "invalid"} site := models.Site{ID: 1, Type: "invalid"}
result := RunCheck(context.Background(), site, nil, nil, false, false) result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN for unknown type, got %s", result.Status) t.Errorf("expected DOWN for unknown type, got %s", result.Status)
@@ -251,10 +213,10 @@ func TestIsCodeAccepted(t *testing.T) {
} }
func TestSiteTimeout(t *testing.T) { func TestSiteTimeout(t *testing.T) {
if got := siteTimeout(models.SiteConfig{Timeout: 0}); got != 5*time.Second { if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second {
t.Errorf("expected 5s default, got %v", got) t.Errorf("expected 5s default, got %v", got)
} }
if got := siteTimeout(models.SiteConfig{Timeout: 10}); got != 10*time.Second { if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second {
t.Errorf("expected 10s, got %v", got) t.Errorf("expected 10s, got %v", got)
} }
} }
-64
View File
@@ -1,64 +0,0 @@
package monitor
import (
"context"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
)
// dbWrite is a single unit of deferred persistence. The engine enqueues these
// onto a buffered channel; a single writer goroutine drains and executes them,
// serializing all writes through one connection and surfacing errors instead of
// discarding them. desc names the write for diagnostics on drop/failure.
type dbWrite interface {
exec(ctx context.Context, s store.Store) error
desc() string
}
type writeLog struct{ message string }
func (w writeLog) exec(ctx context.Context, s store.Store) error { return s.SaveLog(ctx, w.message) }
func (w writeLog) desc() string { return "log" }
type writeCheck struct {
siteID int
latencyNs int64
isUp bool
}
func (w writeCheck) exec(ctx context.Context, s store.Store) error {
return s.SaveCheck(ctx, w.siteID, w.latencyNs, w.isUp)
}
func (w writeCheck) desc() string { return "check" }
type writeStateChange struct {
siteID int
fromStatus string
toStatus string
reason string
}
func (w writeStateChange) exec(ctx context.Context, s store.Store) error {
return s.SaveStateChange(ctx, w.siteID, w.fromStatus, w.toStatus, w.reason)
}
func (w writeStateChange) desc() string { return "state-change" }
type writeAlertHealth struct{ rec models.AlertHealthRecord }
func (w writeAlertHealth) exec(ctx context.Context, s store.Store) error {
return s.SaveAlertHealth(ctx, w.rec)
}
func (w writeAlertHealth) desc() string { return "alert-health" }
type writeProbeCheck struct {
siteID int
nodeID string
latencyNs int64
isUp bool
}
func (w writeProbeCheck) exec(ctx context.Context, s store.Store) error {
return s.SaveCheckFromNode(ctx, w.siteID, w.nodeID, w.latencyNs, w.isUp)
}
func (w writeProbeCheck) desc() string { return "probe-check" }
+3 -6
View File
@@ -1,9 +1,6 @@
package monitor package monitor
import ( import "time"
"context"
"time"
)
const maxHistoryLen = 60 const maxHistoryLen = 60
@@ -15,7 +12,7 @@ type SiteHistory struct {
} }
func (e *Engine) InitHistory() { func (e *Engine) InitHistory() {
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen) all, err := e.db.LoadAllHistory(maxHistoryLen)
if err != nil { if err != nil {
e.AddLog("Failed to load check history: " + err.Error()) e.AddLog("Failed to load check history: " + err.Error())
return return
@@ -64,7 +61,7 @@ func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) {
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
} }
e.enqueueWrite(writeCheck{siteID: siteID, latencyNs: latency.Nanoseconds(), isUp: isUp}) go func() { _ = e.db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }()
} }
func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) { func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) {
File diff suppressed because it is too large Load Diff
+126 -542
View File
@@ -1,14 +1,12 @@
package monitor package monitor
import ( import (
"context"
"fmt" "fmt"
"sync" "sync"
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store --- // --- Mock Store ---
@@ -20,9 +18,8 @@ type savedCheck struct {
} }
type mockStore struct { type mockStore struct {
storetest.BaseMock
mu sync.Mutex mu sync.Mutex
sites []models.SiteConfig sites []models.Site
alerts map[int]models.AlertConfig alerts map[int]models.AlertConfig
maintenance map[int]bool maintenance map[int]bool
logs []string logs []string
@@ -40,19 +37,55 @@ func newMockStore() *mockStore {
} }
} }
func (m *mockStore) GetSites(context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) GetActiveMaintenanceWindows(context.Context) ([]models.MaintenanceWindow, error) { func (m *mockStore) AddSite(models.Site) error { return nil }
m.mu.Lock() func (m *mockStore) UpdateSite(models.Site) error { return nil }
defer m.mu.Unlock() func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
var windows []models.MaintenanceWindow func (m *mockStore) DeleteSite(int) error { return nil }
for id := range m.maintenance { func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
windows = append(windows, models.MaintenanceWindow{MonitorID: id}) func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
} func (m *mockStore) DeleteAlert(int) error { return nil }
return windows, 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) 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) 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) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
return nil, nil
}
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return 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) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) {
return nil, nil
}
func (m *mockStore) Close() error { return nil }
func (m *mockStore) GetAllAlerts(context.Context) ([]models.AlertConfig, error) { func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
var result []models.AlertConfig var result []models.AlertConfig
@@ -62,7 +95,7 @@ func (m *mockStore) GetAllAlerts(context.Context) ([]models.AlertConfig, error)
return result, nil return result, nil
} }
func (m *mockStore) GetAlert(_ context.Context, id int) (models.AlertConfig, error) { func (m *mockStore) GetAlert(id int) (models.AlertConfig, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.getAlertCalls = append(m.getAlertCalls, id) m.getAlertCalls = append(m.getAlertCalls, id)
@@ -72,7 +105,7 @@ func (m *mockStore) GetAlert(_ context.Context, id int) (models.AlertConfig, err
return models.AlertConfig{}, fmt.Errorf("alert %d not found", id) return models.AlertConfig{}, fmt.Errorf("alert %d not found", id)
} }
func (m *mockStore) GetAlertByName(_ context.Context, name string) (models.AlertConfig, error) { func (m *mockStore) GetAlertByName(name string) (models.AlertConfig, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
for _, a := range m.alerts { for _, a := range m.alerts {
@@ -83,31 +116,31 @@ func (m *mockStore) GetAlertByName(_ context.Context, name string) (models.Alert
return models.AlertConfig{}, fmt.Errorf("alert %q not found", name) return models.AlertConfig{}, fmt.Errorf("alert %q not found", name)
} }
func (m *mockStore) IsMonitorInMaintenance(_ context.Context, id int) (bool, error) { func (m *mockStore) IsMonitorInMaintenance(id int) (bool, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
return m.maintenance[id], nil return m.maintenance[id], nil
} }
func (m *mockStore) SaveCheck(_ context.Context, siteID int, latencyNs int64, isUp bool) error { func (m *mockStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.savedChecks = append(m.savedChecks, savedCheck{siteID, latencyNs, isUp}) m.savedChecks = append(m.savedChecks, savedCheck{siteID, latencyNs, isUp})
return nil return nil
} }
func (m *mockStore) SaveLog(_ context.Context, msg string) error { func (m *mockStore) SaveLog(msg string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.savedLogs = append(m.savedLogs, msg) m.savedLogs = append(m.savedLogs, msg)
return nil return nil
} }
func (m *mockStore) LoadLogs(_ context.Context, _ int) ([]string, error) { func (m *mockStore) LoadLogs(limit int) ([]string, error) {
return m.logs, nil return m.logs, nil
} }
func (m *mockStore) LoadAllHistory(_ context.Context, _ int) (map[int][]models.CheckRecord, error) { func (m *mockStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) {
return m.history, nil return m.history, nil
} }
@@ -148,10 +181,7 @@ func (m *mockStore) getAlertCallsSnapshot() []int {
func TestHandleStatusChange_PendingToUp(t *testing.T) { func TestHandleStatusChange_PendingToUp(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "PENDING", MaxRetries: 3, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1},
SiteState: models.SiteState{Status: "PENDING"},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "") e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
@@ -172,10 +202,7 @@ func TestHandleStatusChange_PendingToUp(t *testing.T) {
func TestHandleStatusChange_UpIncrementFailure(t *testing.T) { func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 3, FailureCount: 0}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error") e.handleStatusChange(site, "DOWN", 500, 0, "test error")
@@ -193,10 +220,7 @@ func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 2, FailureCount: 2, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 2},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "DOWN", 500, 0, "test error") e.handleStatusChange(site, "DOWN", 500, 0, "test error")
@@ -219,10 +243,7 @@ func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, FailureCount: 0, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error") e.handleStatusChange(site, "DOWN", 0, 0, "test error")
@@ -241,10 +262,7 @@ func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "DOWN", FailureCount: 4, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 4},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "") e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
@@ -265,10 +283,7 @@ func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
func TestHandleStatusChange_DownStaysDown(t *testing.T) { func TestHandleStatusChange_DownStaysDown(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "DOWN", MaxRetries: 2, FailureCount: 3}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "DOWN", 0, 0, "test error") e.handleStatusChange(site, "DOWN", 0, 0, "test error")
@@ -287,10 +302,7 @@ func TestHandleStatusChange_SSLExpired(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired") e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
@@ -310,12 +322,8 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
ms.maintenance[1] = true ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "DOWN", 0, 0, "test error") e.handleStatusChange(site, "DOWN", 0, 0, "test error")
@@ -345,12 +353,8 @@ func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
ms.maintenance[1] = true ms.maintenance[1] = true
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "DOWN", AlertID: 1}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN"},
}
injectSite(e, site) injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "") e.handleStatusChange(site, "UP", 200, 0, "")
@@ -369,8 +373,10 @@ func TestHandleStatusChange_SSLWarning(t *testing.T) {
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, ID: 1, Name: "test", Status: "UP", Type: "http",
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, CheckSSL: true, HasSSL: true, ExpiryThreshold: 30,
SentSSLWarning: false, AlertID: 1,
CertExpiry: time.Now().Add(15 * 24 * time.Hour),
} }
injectSite(e, site) injectSite(e, site)
@@ -390,8 +396,10 @@ func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, ID: 1, Name: "test", Status: "UP", Type: "http",
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, CheckSSL: true, HasSSL: true, ExpiryThreshold: 30,
SentSSLWarning: true, AlertID: 1,
CertExpiry: time.Now().Add(15 * 24 * time.Hour),
} }
injectSite(e, site) injectSite(e, site)
@@ -407,8 +415,10 @@ func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30}, ID: 1, Name: "test", Status: "UP", Type: "http",
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)}, CheckSSL: true, HasSSL: true, ExpiryThreshold: 30,
SentSSLWarning: true,
CertExpiry: time.Now().Add(60 * 24 * time.Hour),
} }
injectSite(e, site) injectSite(e, site)
@@ -426,11 +436,12 @@ func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1}, ID: 1, Name: "test", Status: "UP", Type: "http",
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)}, CheckSSL: true, HasSSL: true, ExpiryThreshold: 30,
SentSSLWarning: false, AlertID: 1,
CertExpiry: time.Now().Add(15 * 24 * time.Hour),
} }
injectSite(e, site) injectSite(e, site)
e.refreshMaintenanceCache(context.Background())
e.handleStatusChange(site, "UP", 200, 0, "") e.handleStatusChange(site, "UP", 200, 0, "")
@@ -447,10 +458,7 @@ func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
func TestHandleStatusChange_InactiveEngine(t *testing.T) { func TestHandleStatusChange_InactiveEngine(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
e.SetActive(false) e.SetActive(false)
@@ -467,10 +475,7 @@ func TestHandleStatusChange_InactiveEngine(t *testing.T) {
func TestRecordHeartbeat_ValidToken(t *testing.T) { func TestRecordHeartbeat_ValidToken(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
if !e.RecordHeartbeat("abc123") { if !e.RecordHeartbeat("abc123") {
@@ -490,10 +495,7 @@ func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}} ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "push-test", Type: "push", Token: "abc123", Status: "DOWN", AlertID: 1, FailureCount: 3}
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1},
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
}
injectSite(e, site) injectSite(e, site)
if !e.RecordHeartbeat("abc123") { if !e.RecordHeartbeat("abc123") {
@@ -525,10 +527,7 @@ func TestRecordHeartbeat_UnknownToken(t *testing.T) {
func TestRecordHeartbeat_InactiveEngine(t *testing.T) { func TestRecordHeartbeat_InactiveEngine(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Type: "push", Token: "abc123", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
e.SetActive(false) e.SetActive(false)
@@ -543,12 +542,13 @@ func TestCheckPush_DeadlineMissed(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0}, ID: 1, Name: "push", Type: "push", Status: "UP",
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)}, Interval: 10, MaxRetries: 0,
LastCheck: time.Now().Add(-120 * time.Second),
} }
injectSite(e, site) injectSite(e, site)
e.checkPush(context.Background(), site) e.checkPush(site)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "DOWN" { if s.Status != "DOWN" {
@@ -560,12 +560,13 @@ func TestCheckPush_OverdueBecomesLate(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300}, ID: 1, Name: "push", Type: "push", Status: "UP",
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)}, Interval: 300,
LastCheck: time.Now().Add(-310 * time.Second),
} }
injectSite(e, site) injectSite(e, site)
e.checkPush(context.Background(), site) e.checkPush(site)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "LATE" { if s.Status != "LATE" {
@@ -573,35 +574,16 @@ func TestCheckPush_OverdueBecomesLate(t *testing.T) {
} }
} }
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) { func TestCheckPush_WithinDeadline(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60}, ID: 1, Name: "push", Type: "push", Status: "UP",
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()}, Interval: 60, LastCheck: time.Now(),
} }
injectSite(e, site) injectSite(e, site)
e.checkPush(context.Background(), site) e.checkPush(site)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "UP" { if s.Status != "UP" {
@@ -613,12 +595,12 @@ func TestCheckPush_PendingStaysPending(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60}, ID: 1, Name: "push", Type: "push", Status: "PENDING",
SiteState: models.SiteState{Status: "PENDING"}, Interval: 60,
} }
injectSite(e, site) injectSite(e, site)
e.checkPush(context.Background(), site) e.checkPush(site)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "PENDING" { if s.Status != "PENDING" {
@@ -631,23 +613,14 @@ func TestCheckPush_PendingStaysPending(t *testing.T) {
func TestCheckGroup_AllChildrenUp(t *testing.T) { func TestCheckGroup_AllChildrenUp(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
group := models.Site{ group := models.Site{ID: 1, Name: "group", Type: "group", Status: "PENDING"}
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"}
SiteState: models.SiteState{Status: "PENDING"}, child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, 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: "UP"},
}
injectSite(e, group) injectSite(e, group)
injectSite(e, child1) injectSite(e, child1)
injectSite(e, child2) injectSite(e, child2)
e.checkGroup(context.Background(), group) e.checkGroup(group)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "UP" { if s.Status != "UP" {
@@ -658,23 +631,14 @@ func TestCheckGroup_AllChildrenUp(t *testing.T) {
func TestCheckGroup_OneChildDown(t *testing.T) { func TestCheckGroup_OneChildDown(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
group := models.Site{ group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"}
SiteState: models.SiteState{Status: "UP"}, child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"}
}
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, group)
injectSite(e, child1) injectSite(e, child1)
injectSite(e, child2) injectSite(e, child2)
e.checkGroup(context.Background(), group) e.checkGroup(group)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "DOWN" { if s.Status != "DOWN" {
@@ -685,22 +649,14 @@ func TestCheckGroup_OneChildDown(t *testing.T) {
func TestCheckGroup_PausedChildIgnored(t *testing.T) { func TestCheckGroup_PausedChildIgnored(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
group := models.Site{ group := models.Site{ID: 1, Name: "group", Type: "group"}
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"}
} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN", Paused: true}
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, group)
injectSite(e, child1) injectSite(e, child1)
injectSite(e, child2) injectSite(e, child2)
e.checkGroup(context.Background(), group) e.checkGroup(group)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "UP" { if s.Status != "UP" {
@@ -712,23 +668,14 @@ func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
ms := newMockStore() ms := newMockStore()
ms.maintenance[3] = true ms.maintenance[3] = true
e := newTestEngine(ms) e := newTestEngine(ms)
group := models.Site{ group := models.Site{ID: 1, Name: "group", Type: "group"}
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"}, child1 := models.Site{ID: 2, Name: "child1", Type: "http", ParentID: 1, Status: "UP"}
} child2 := models.Site{ID: 3, Name: "child2", Type: "http", ParentID: 1, Status: "DOWN"}
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, group)
injectSite(e, child1) injectSite(e, child1)
injectSite(e, child2) injectSite(e, child2)
e.refreshMaintenanceCache(context.Background())
e.checkGroup(context.Background(), group) e.checkGroup(group)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "UP" { if s.Status != "UP" {
@@ -739,13 +686,10 @@ func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
func TestCheckGroup_NoChildren(t *testing.T) { func TestCheckGroup_NoChildren(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
group := models.Site{ group := models.Site{ID: 1, Name: "group", Type: "group", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, group) injectSite(e, group)
e.checkGroup(context.Background(), group) e.checkGroup(group)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
if s.Status != "PENDING" { if s.Status != "PENDING" {
@@ -837,13 +781,10 @@ func TestInitHistory_LoadsFromDB(t *testing.T) {
func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) { func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", URL: "http://old.com", Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond}
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) injectSite(e, site)
updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60} updated := models.Site{ID: 1, Name: "test", URL: "http://new.com", Interval: 60}
e.UpdateSiteConfig(updated) e.UpdateSiteConfig(updated)
s, _ := getSite(e, 1) s, _ := getSite(e, 1)
@@ -864,10 +805,7 @@ func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
func TestRemoveSite_CleansUp(t *testing.T) { func TestRemoveSite_CleansUp(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Type: "push", Token: "tok1", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
e.recordCheck(1, 5*time.Millisecond, true) e.recordCheck(1, 5*time.Millisecond, true)
@@ -887,10 +825,7 @@ func TestRemoveSite_CleansUp(t *testing.T) {
func TestToggleSitePause(t *testing.T) { func TestToggleSitePause(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP"}
SiteConfig: models.SiteConfig{ID: 1, Name: "test"},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
paused := e.ToggleSitePause(1) paused := e.ToggleSitePause(1)
@@ -919,14 +854,8 @@ func TestToggleSitePause_NonexistentSite(t *testing.T) {
func TestGetAllSites_ReturnsCopy(t *testing.T) { func TestGetAllSites_ReturnsCopy(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
injectSite(e, models.Site{ injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"})
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"}, injectSite(e, models.Site{ID: 2, Name: "s2", Status: "DOWN"})
SiteState: models.SiteState{Status: "UP"},
})
injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: 2, Name: "s2"},
SiteState: models.SiteState{Status: "DOWN"},
})
sites := e.GetAllSites() sites := e.GetAllSites()
if len(sites) != 2 { if len(sites) != 2 {
@@ -945,13 +874,10 @@ func TestGetAllSites_ReturnsCopy(t *testing.T) {
func TestGetLiveState_ReturnsCopy(t *testing.T) { func TestGetLiveState_ReturnsCopy(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
injectSite(e, models.Site{ injectSite(e, models.Site{ID: 1, Name: "s1", Status: "UP"})
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
SiteState: models.SiteState{Status: "UP"},
})
state := e.GetLiveState() state := e.GetLiveState()
state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}} state[1] = models.Site{Name: "mutated"}
fresh := e.GetLiveState() fresh := e.GetLiveState()
if fresh[1].Name == "mutated" { if fresh[1].Name == "mutated" {
@@ -1067,8 +993,7 @@ func TestConcurrent_RecordHeartbeat(t *testing.T) {
e := newTestEngine(ms) e := newTestEngine(ms)
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
injectSite(e, models.Site{ injectSite(e, models.Site{
SiteConfig: models.SiteConfig{ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1)}, ID: i + 1, Type: "push", Token: fmt.Sprintf("tok-%d", i+1), Status: "UP",
SiteState: models.SiteState{Status: "UP"},
}) })
} }
@@ -1086,10 +1011,7 @@ func TestConcurrent_RecordHeartbeat(t *testing.T) {
func TestConcurrent_HandleStatusChangeAndGetState(t *testing.T) { func TestConcurrent_HandleStatusChangeAndGetState(t *testing.T) {
ms := newMockStore() ms := newMockStore()
e := newTestEngine(ms) e := newTestEngine(ms)
site := models.Site{ site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 100}
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 100},
SiteState: models.SiteState{Status: "UP"},
}
injectSite(e, site) injectSite(e, site)
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -1134,344 +1056,6 @@ func TestConcurrent_RecordCheckAndGetHistory(t *testing.T) {
} }
} }
// --- 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 11: single DB writer ---
// Writes enqueued through the engine are persisted by the writer goroutine and
// fully drained when the engine stops — no fire-and-forget, no lost writes.
func TestDBWriter_DrainsOnStop(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
e.Start(context.Background())
e.enqueueWrite(writeCheck{siteID: 7, latencyNs: 100, isUp: true})
e.enqueueWrite(writeLog{message: "drain-me"})
e.Stop() // blocks until the writer has drained the queue
ms.mu.Lock()
defer ms.mu.Unlock()
gotCheck := false
for _, c := range ms.savedChecks {
if c.SiteID == 7 {
gotCheck = true
}
}
if !gotCheck {
t.Error("check was not persisted before Stop returned")
}
gotLog := false
for _, l := range ms.savedLogs {
if l == "drain-me" {
gotLog = true
}
}
if !gotLog {
t.Error("log was not persisted before Stop returned")
}
}
// Stop must be idempotent — safe to call more than once.
func TestEngineStop_Idempotent(t *testing.T) {
ms := newMockStore()
e := newTestEngine(ms)
e.Start(context.Background())
e.Stop()
e.Stop() // must not panic or block
}
// --- Group 12: Phase 3 engine correctness ---
// 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")
}
}
// 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)
}
}
// 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")
}
}
// 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")
}
}
// --- Utilities --- // --- Utilities ---
func containsStr(s, substr string) bool { func containsStr(s, substr string) bool {
-5
View File
@@ -11,11 +11,9 @@ var privateRanges []*net.IPNet
func init() { func init() {
cidrs := []string{ cidrs := []string{
"0.0.0.0/8",
"127.0.0.0/8", "127.0.0.0/8",
"::1/128", "::1/128",
"10.0.0.0/8", "10.0.0.0/8",
"100.64.0.0/10",
"172.16.0.0/12", "172.16.0.0/12",
"192.168.0.0/16", "192.168.0.0/16",
"169.254.0.0/16", "169.254.0.0/16",
@@ -29,9 +27,6 @@ func init() {
} }
func isPrivateIP(ip net.IP) bool { func isPrivateIP(ip net.IP) bool {
if ip.IsUnspecified() || ip.IsMulticast() || ip.IsLoopback() {
return true
}
for _, network := range privateRanges { for _, network := range privateRanges {
if network.Contains(ip) { if network.Contains(ip) {
return true return true
+19 -11
View File
@@ -16,14 +16,14 @@ type SLAReport struct {
MTBF time.Duration MTBF time.Duration
} }
func ComputeSLA(changes []models.StateChange, currentStatus models.Status, window time.Duration) SLAReport { func ComputeSLA(changes []models.StateChange, currentStatus string, window time.Duration) SLAReport {
now := time.Now() now := time.Now()
windowStart := now.Add(-window) windowStart := now.Add(-window)
report := SLAReport{Window: window} report := SLAReport{Window: window}
if len(changes) == 0 { if len(changes) == 0 {
if models.Status(currentStatus).IsBroken() { if isDown(currentStatus) {
report.UptimePct = 0 report.UptimePct = 0
report.Downtime = window report.Downtime = window
} else { } else {
@@ -40,7 +40,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus models.Status, windo
} }
// Determine status at window start: last transition before or at windowStart. // Determine status at window start: last transition before or at windowStart.
statusAtStart := string(models.StatusUp) statusAtStart := "UP"
for i := len(sorted) - 1; i >= 0; i-- { for i := len(sorted) - 1; i >= 0; i-- {
if !sorted[i].ChangedAt.After(windowStart) { if !sorted[i].ChangedAt.After(windowStart) {
statusAtStart = sorted[i].ToStatus statusAtStart = sorted[i].ToStatus
@@ -51,7 +51,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus models.Status, windo
var upTime, downTime time.Duration var upTime, downTime time.Duration
var outages []time.Duration var outages []time.Duration
cursor := windowStart cursor := windowStart
wasDown := models.Status(statusAtStart).IsBroken() wasDown := isDown(statusAtStart)
if wasDown { if wasDown {
report.OutageCount = 1 report.OutageCount = 1
@@ -77,7 +77,7 @@ func ComputeSLA(changes []models.StateChange, currentStatus models.Status, windo
upTime += seg upTime += seg
} }
newDown := models.Status(sc.ToStatus).IsBroken() newDown := isDown(sc.ToStatus)
if !wasDown && newDown { if !wasDown && newDown {
report.OutageCount++ report.OutageCount++
outageStart = sc.ChangedAt outageStart = sc.ChangedAt
@@ -127,15 +127,19 @@ func ComputeSLA(changes []models.StateChange, currentStatus models.Status, windo
return report return report
} }
func ComputeDailyBreakdown(changes []models.StateChange, currentStatus models.Status, days int, now time.Time) []DayReport { func ComputeDailyBreakdown(changes []models.StateChange, currentStatus string, days int) []DayReport {
now := time.Now()
reports := make([]DayReport, days) reports := make([]DayReport, days)
for i := 0; i < days; i++ { 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(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour)
dayEnd := time.Date(now.Year(), now.Month(), now.Day()-i+1, 0, 0, 0, 0, now.Location())
if i == 0 { if i == 0 {
dayEnd = now dayEnd = now
} }
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(-time.Duration(i) * 24 * time.Hour)
if i > 0 {
dayEnd = dayStart.Add(24 * time.Hour)
}
windowChanges := filterChangesForWindow(changes, dayStart, dayEnd) windowChanges := filterChangesForWindow(changes, dayStart, dayEnd)
@@ -156,6 +160,10 @@ type DayReport struct {
UptimePct float64 UptimePct float64
} }
func isDown(status string) bool {
return status == "DOWN" || status == "SSL EXP"
}
func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange { func filterChangesForWindow(changes []models.StateChange, start, end time.Time) []models.StateChange {
var filtered []models.StateChange var filtered []models.StateChange
for _, sc := range changes { for _, sc := range changes {
@@ -173,7 +181,7 @@ func inferStatusAt(changes []models.StateChange, at time.Time) string {
return sc.ToStatus return sc.ToStatus
} }
} }
return string(models.StatusUp) return "UP"
} }
func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 { func computeSLAForWindow(changes []models.StateChange, statusAtStart string, start, end time.Time) float64 {
@@ -186,7 +194,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
var upTime, downTime time.Duration var upTime, downTime time.Duration
cursor := start cursor := start
wasDown := models.Status(statusAtStart).IsBroken() wasDown := isDown(statusAtStart)
for _, sc := range sorted { for _, sc := range sorted {
if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) { if sc.ChangedAt.Before(start) || !sc.ChangedAt.Before(end) {
@@ -198,7 +206,7 @@ func computeSLAForWindow(changes []models.StateChange, statusAtStart string, sta
} else { } else {
upTime += seg upTime += seg
} }
wasDown = models.Status(sc.ToStatus).IsBroken() wasDown = isDown(sc.ToStatus)
cursor = sc.ChangedAt cursor = sc.ChangedAt
} }
+15 -16
View File
@@ -118,14 +118,13 @@ func TestComputeSLA_LateNotDown(t *testing.T) {
} }
func TestComputeDailyBreakdown(t *testing.T) { func TestComputeDailyBreakdown(t *testing.T) {
// Use a fixed time well past midnight so the outage always falls within today's window. now := time.Now()
now := time.Date(2026, 6, 4, 15, 0, 0, 0, time.UTC)
changes := []models.StateChange{ changes := []models.StateChange{
{ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)}, {ToStatus: "UP", ChangedAt: now.Add(-1 * time.Hour)},
{ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)}, {ToStatus: "DOWN", FromStatus: "UP", ChangedAt: now.Add(-2 * time.Hour)},
} }
days := ComputeDailyBreakdown(changes, "UP", 7, now) days := ComputeDailyBreakdown(changes, "UP", 7)
if len(days) != 7 { if len(days) != 7 {
t.Fatalf("expected 7 days, got %d", len(days)) t.Fatalf("expected 7 days, got %d", len(days))
@@ -137,24 +136,24 @@ func TestComputeDailyBreakdown(t *testing.T) {
} }
} }
func TestIsBroken(t *testing.T) { func TestIsDown(t *testing.T) {
if !models.StatusDown.IsBroken() { if !isDown("DOWN") {
t.Error("DOWN should be broken") t.Error("DOWN should be down")
} }
if !models.StatusSSLExp.IsBroken() { if !isDown("SSL EXP") {
t.Error("SSL EXP should be broken") t.Error("SSL EXP should be down")
} }
if models.StatusUp.IsBroken() { if isDown("UP") {
t.Error("UP should not be broken") t.Error("UP should not be down")
} }
if models.StatusLate.IsBroken() { if isDown("LATE") {
t.Error("LATE should not be broken") t.Error("LATE should not be down")
} }
if models.StatusStale.IsBroken() { if isDown("STALE") {
t.Error("STALE should not be broken") t.Error("STALE should not be down")
} }
if models.StatusPending.IsBroken() { if isDown("PENDING") {
t.Error("PENDING should not be broken") t.Error("PENDING should not be down")
} }
} }
+8 -82
View File
@@ -3,17 +3,10 @@ package server
import ( import (
"net" "net"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
) )
// maxVisitors caps the rate-limiter map so a flood of distinct keys can't grow
// it without bound. With the trusted-proxy gate below, keys come from real peer
// addresses, so this is a defense-in-depth ceiling rather than the primary
// guard.
const maxVisitors = 10000
type visitor struct { type visitor struct {
tokens float64 tokens float64
lastSeen time.Time lastSeen time.Time
@@ -24,26 +17,18 @@ type RateLimiter struct {
visitors map[string]*visitor visitors map[string]*visitor
rate float64 rate float64
burst float64 burst float64
trusted []*net.IPNet
stop chan struct{}
} }
func NewRateLimiter(requestsPerMinute int, trusted []*net.IPNet) *RateLimiter { func NewRateLimiter(requestsPerMinute int) *RateLimiter {
rl := &RateLimiter{ rl := &RateLimiter{
visitors: make(map[string]*visitor), visitors: make(map[string]*visitor),
rate: float64(requestsPerMinute) / 60.0, rate: float64(requestsPerMinute) / 60.0,
burst: float64(requestsPerMinute), burst: float64(requestsPerMinute),
trusted: trusted,
stop: make(chan struct{}),
} }
go rl.cleanup() go rl.cleanup()
return rl return rl
} }
func (rl *RateLimiter) Stop() {
close(rl.stop)
}
func (rl *RateLimiter) Allow(ip string) bool { func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock() rl.mu.Lock()
defer rl.mu.Unlock() defer rl.mu.Unlock()
@@ -52,9 +37,6 @@ func (rl *RateLimiter) Allow(ip string) bool {
now := time.Now() now := time.Now()
if !exists { if !exists {
if len(rl.visitors) >= maxVisitors {
rl.evictOldest()
}
rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now} rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now}
return true return true
} }
@@ -73,28 +55,9 @@ func (rl *RateLimiter) Allow(ip string) bool {
return true return true
} }
// evictOldest removes the least-recently-seen visitor. Called only when the map
// is at capacity, so the O(n) scan is rare. Caller holds rl.mu.
func (rl *RateLimiter) evictOldest() {
var oldestKey string
var oldest time.Time
for k, v := range rl.visitors {
if oldestKey == "" || v.lastSeen.Before(oldest) {
oldestKey = k
oldest = v.lastSeen
}
}
if oldestKey != "" {
delete(rl.visitors, oldestKey)
}
}
func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for { for {
select { time.Sleep(5 * time.Minute)
case <-ticker.C:
rl.mu.Lock() rl.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute) cutoff := time.Now().Add(-10 * time.Minute)
for ip, v := range rl.visitors { for ip, v := range rl.visitors {
@@ -103,60 +66,23 @@ func (rl *RateLimiter) cleanup() {
} }
} }
rl.mu.Unlock() rl.mu.Unlock()
case <-rl.stop:
return
}
} }
} }
// clientIP determines the rate-limit key for a request. X-Forwarded-For is only func clientIP(r *http.Request) string {
// honored when the immediate peer (RemoteAddr) is a configured trusted proxy; if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// otherwise the header is attacker-controlled and ignored, so a spoofed XFF return fwd
// can't mint unlimited distinct keys (rate-limit bypass + memory DoS). When the }
// peer is trusted, the right-most address that is not itself a trusted proxy is
// the real client (RFC 7239 right-most-untrusted-hop).
func clientIP(r *http.Request, trusted []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil { if err != nil {
host = r.RemoteAddr return r.RemoteAddr
}
if len(trusted) == 0 || !ipInCIDRs(net.ParseIP(host), trusted) {
return host
}
xff := r.Header.Get("X-Forwarded-For")
if xff == "" {
return host
}
parts := strings.Split(xff, ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !ipInCIDRs(ip, trusted) {
return ip.String()
}
} }
return host return host
} }
func ipInCIDRs(ip net.IP, cidrs []*net.IPNet) bool {
if ip == nil {
return false
}
for _, c := range cidrs {
if c.Contains(ip) {
return true
}
}
return false
}
func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc { func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow(clientIP(r, limiter.trusted)) { if !limiter.Allow(clientIP(r)) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return return
} }
+404 -459
View File
@@ -5,8 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log"
"net"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
@@ -21,395 +20,6 @@ import (
const maxRequestBody = 1 << 20 const maxRequestBody = 1 << 20
type ServerConfig struct {
Port int
EnableStatus bool
Title string
ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
TrustedProxies []*net.IPNet
QuietHTTPLog bool
}
type Server struct {
cfg ServerConfig
store store.Store
eng *monitor.Engine
pushRL *RateLimiter
probeRL *RateLimiter
backupRL *RateLimiter
statusRL *RateLimiter
}
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
return &Server{
cfg: cfg,
store: s,
eng: eng,
pushRL: NewRateLimiter(60, cfg.TrustedProxies),
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
statusRL: NewRateLimiter(120, 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: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
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
}
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
}
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return
}
data, err := s.store.ExportData(r.Context())
if err != nil {
slog.Error("export failed", "err", err)
http.Error(w, "Export failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("redact_secrets") != "false" {
for i := range data.Alerts {
data.Alerts[i].Settings = models.RedactAlertSettings(data.Alerts[i].Type, data.Alerts[i].Settings)
}
}
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// API import never modifies users — cluster-secret holder shouldn't be
// able to replace admin accounts. CLI restore still does full import.
data.Users = nil
if err := s.store.ImportData(r.Context(), data); err != nil {
slog.Error("import failed", "err", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("Import Successful (users excluded — manage via CLI or UPTOP_KEYS)"))
}
func (s *Server) handleKumaImport(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
slog.Error("invalid Kuma JSON", "err", err)
http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return
}
backup := importer.ConvertKuma(&kb)
if err := s.store.ImportData(r.Context(), backup); err != nil {
slog.Error("Kuma import failed", "err", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}
func (s *Server) handleProbeRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.store.RegisterNode(r.Context(), models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil {
slog.Error("probe registration failed", "err", err)
http.Error(w, "Registration failed", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}
func (s *Server) handleProbeAssignments(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
nodeID := r.URL.Query().Get("node_id")
var nodeRegion string
if nodeID != "" {
if node, err := s.store.GetNode(r.Context(), nodeID); err == nil {
nodeRegion = node.Region
}
}
sites := s.eng.GetAllSites()
var assigned []models.Site
for _, site := range sites {
if site.Paused || site.Type == "push" || site.Type == "group" {
continue
}
if site.Regions != "" && nodeRegion != "" {
matched := false
for _, reg := range strings.Split(site.Regions, ",") {
if strings.TrimSpace(reg) == nodeRegion {
matched = true
break
}
}
if !matched {
continue
}
}
assigned = append(assigned, site)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}
func (s *Server) handleProbeResults(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
NodeID string `json:"node_id"`
Results []struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
ErrorReason string `json:"error_reason"`
} `json:"results"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.NodeID == "" {
http.Error(w, "node_id is required", http.StatusBadRequest)
return
}
for _, result := range req.Results {
s.eng.EnqueueProbeCheck(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp)
s.eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason)
}
if err := s.store.UpdateNodeLastSeen(r.Context(), req.NodeID); err != nil {
slog.Error("node last-seen update failed", "err", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !s.cfg.MetricsPublic {
if !s.requireAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
metrics.Handler(s.eng)(w, r)
}
func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) {
renderStatusPage(w, s.cfg.Title, s.eng)
}
func (s *Server) handleStatusJSON(w http.ResponseWriter, r *http.Request) {
state := s.eng.GetLiveState()
activeWindows, _ := s.store.GetActiveMaintenanceWindows(r.Context())
maintSet := make(map[int]bool)
allInMaint := false
for _, mw := range activeWindows {
if mw.Type != "maintenance" {
continue
}
if mw.MonitorID == 0 {
allInMaint = true
} else {
maintSet[mw.MonitorID] = true
}
}
public := make(map[int]statusSite, len(state))
for id, site := range state {
displayStatus := string(site.Status)
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
displayStatus = "MAINT"
}
public[id] = statusSite{
Name: site.Name,
Type: site.Type,
URL: site.URL,
Status: displayStatus,
Paused: site.Paused,
LastCheck: site.LastCheck,
Latency: site.Latency,
}
}
if s.cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", s.cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(public) //nolint:errcheck
}
// --- Helpers ---
func checkSecret(got, want string) bool { func checkSecret(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
} }
@@ -422,78 +32,21 @@ func extractBearerToken(r *http.Request) string {
return "" return ""
} }
// statusSite is the public DTO for /status/json. var sensitiveKeys = map[string]bool{
type statusSite struct { "pass": true, "password": true, "token": true,
Name string "routing_key": true, "user": true, "username": true,
Type string
URL string
Status string
Paused bool
LastCheck time.Time
Latency time.Duration
} }
// --- Middleware --- func redactSettings(settings map[string]string) map[string]string {
redacted := make(map[string]string, len(settings))
type statusWriter struct { for k, v := range settings {
http.ResponseWriter if sensitiveKeys[k] && v != "" {
code int redacted[k] = "***REDACTED***"
} } else {
redacted[k] = v
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(trusted []*net.IPNet, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(sw, r)
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
slog.Info("http request", "method", r.Method, "path", path, "status", sw.code, "duration", time.Since(start).Round(time.Millisecond), "ip", clientIP(r, trusted)) //nolint:gosec // structured slog, not format string
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
sites := eng.GetAllSites()
sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status {
if sites[i].Status == models.StatusDown {
return true
}
if sites[j].Status == models.StatusDown {
return false
} }
} }
return sites[i].Name < sites[j].Name return redacted
})
data := struct {
Title string
Sites []models.Site
}{Title: title, Sites: sites}
if err := statusTpl.Execute(w, data); err != nil {
slog.Error("status page render failed", "err", err)
}
} }
var statusTpl = template.Must(template.New("status").Parse(` var statusTpl = template.Must(template.New("status").Parse(`
@@ -627,3 +180,395 @@ var statusTpl = template.Must(template.New("status").Parse(`
</script> </script>
</body> </body>
</html>`)) </html>`))
type ServerConfig struct {
Port int
EnableStatus bool
Title string
ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
}
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
}
pushRL := NewRateLimiter(60)
probeRL := NewRateLimiter(30)
backupRL := NewRateLimiter(10)
statusRL := NewRateLimiter(120)
mux := http.NewServeMux()
// 1. Push Heartbeat
mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := extractBearerToken(r)
if token == "" {
if qt := r.URL.Query().Get("token"); qt != "" {
token = qt
log.Printf("DEPRECATED: push token in query string — use Authorization: Bearer header instead")
}
}
if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest)
return
}
if eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
} else {
http.Error(w, "Invalid Token", http.StatusNotFound)
}
}))
// 2. Health Check (For Cluster Follower)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})
// 3. Config Export
mux.HandleFunc("/api/backup/export", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return
}
data, err := s.ExportData()
if err != nil {
log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", http.StatusInternalServerError)
return
}
if r.URL.Query().Get("redact_secrets") != "false" {
for i := range data.Alerts {
data.Alerts[i].Settings = redactSettings(data.Alerts[i].Settings)
}
}
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}))
// 4. Config Import
mux.HandleFunc("/api/backup/import", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := s.ImportData(data); err != nil {
log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("Import Successful"))
}))
// 5. Kuma Import
mux.HandleFunc("/api/import/kuma", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err)
http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return
}
backup := importer.ConvertKuma(&kb)
if err := s.ImportData(backup); err != nil {
log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}))
// 6. Probe Registration
mux.HandleFunc("/api/probe/register", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.ID == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
if err := s.RegisterNode(models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil {
log.Printf("Probe register failed: %v", err)
http.Error(w, "Registration failed", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
// 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
nodeID := r.URL.Query().Get("node_id")
var nodeRegion string
if nodeID != "" {
if node, err := s.GetNode(nodeID); err == nil {
nodeRegion = node.Region
}
}
sites := eng.GetAllSites()
var assigned []models.Site
for _, site := range sites {
if site.Paused || site.Type == "push" || site.Type == "group" {
continue
}
if site.Regions != "" && nodeRegion != "" {
matched := false
for _, r := range strings.Split(site.Regions, ",") {
if strings.TrimSpace(r) == nodeRegion {
matched = true
break
}
}
if !matched {
continue
}
}
assigned = append(assigned, site)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}))
// 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct {
NodeID string `json:"node_id"`
Results []struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
ErrorReason string `json:"error_reason"`
} `json:"results"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.NodeID == "" {
http.Error(w, "node_id is required", http.StatusBadRequest)
return
}
for _, result := range req.Results {
if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil {
log.Printf("Failed to save probe result: %v", err)
}
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason)
}
if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
log.Printf("Failed to update node last seen: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
// 9. Prometheus Metrics
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !cfg.MetricsPublic && cfg.ClusterKey != "" {
if !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
metrics.Handler(eng)(w, r)
})
// 10. Status Page
if cfg.EnableStatus {
mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }))
mux.HandleFunc("/status/json", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) {
state := eng.GetLiveState()
activeWindows, _ := s.GetActiveMaintenanceWindows()
maintSet := make(map[int]bool)
allInMaint := false
for _, mw := range activeWindows {
if mw.Type != "maintenance" {
continue
}
if mw.MonitorID == 0 {
allInMaint = true
} else {
maintSet[mw.MonitorID] = true
}
}
for id, site := range state {
site.Token = ""
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
site.Status = "MAINT"
}
state[id] = site
}
if cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck
}))
}
if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" {
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
}
handler := loggingMiddleware(securityHeadersMiddleware(mux))
if cfg.TLSCert != "" {
handler = hstsMiddleware(handler)
}
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
if cfg.TLSCert != "" && cfg.TLSKey != "" {
fmt.Printf("HTTPS Server listening on %s\n", addr)
if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed {
log.Printf("HTTPS server error: %v", err)
}
} else {
fmt.Printf("HTTP Server listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP server error: %v", err)
}
}
}()
return srv
}
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(sw, r)
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
sites := eng.GetAllSites()
sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status {
if sites[i].Status == "DOWN" {
return true
}
if sites[j].Status == "DOWN" {
return false
}
}
return sites[i].Name < sites[j].Name
})
data := struct {
Title string
Sites []models.Site
}{Title: title, Sites: sites}
if err := statusTpl.Execute(w, data); err != nil {
log.Printf("Failed to render status page: %v", err)
}
}
+73 -160
View File
@@ -2,7 +2,6 @@ package server
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
@@ -13,15 +12,13 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store/storetest"
) )
// --- Mock Store --- // --- Mock Store ---
type mockStore struct { type mockStore struct {
storetest.BaseMock
mu sync.Mutex mu sync.Mutex
sites []models.SiteConfig sites []models.Site
alerts []models.AlertConfig alerts []models.AlertConfig
nodes map[string]models.ProbeNode nodes map[string]models.ProbeNode
importedData *models.Backup importedData *models.Backup
@@ -35,26 +32,76 @@ func newMockStore() *mockStore {
} }
} }
func (m *mockStore) GetSites(_ context.Context) ([]models.SiteConfig, error) { return m.sites, nil } func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) { func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
return m.alerts, nil func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
return nil
} }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
}
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
return nil, nil
}
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
func (m *mockStore) GetStateChangesSince(int, time.Time) ([]models.StateChange, error) {
return nil, nil
}
func (m *mockStore) Close() error { return nil }
func (m *mockStore) ExportData(_ context.Context) (models.Backup, error) { func (m *mockStore) ExportData() (models.Backup, error) {
return models.Backup{ return models.Backup{
Sites: m.sites, Sites: m.sites,
Alerts: m.alerts, Alerts: m.alerts,
}, nil }, nil
} }
func (m *mockStore) ImportData(_ context.Context, data models.Backup) error { func (m *mockStore) ImportData(data models.Backup) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.importedData = &data m.importedData = &data
return nil return nil
} }
func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error { func (m *mockStore) RegisterNode(node models.ProbeNode) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.registeredNodes = append(m.registeredNodes, node) m.registeredNodes = append(m.registeredNodes, node)
@@ -62,7 +109,7 @@ func (m *mockStore) RegisterNode(_ context.Context, node models.ProbeNode) error
return nil return nil
} }
func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, error) { func (m *mockStore) GetNode(id string) (models.ProbeNode, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if n, ok := m.nodes[id]; ok { if n, ok := m.nodes[id]; ok {
@@ -71,7 +118,7 @@ func (m *mockStore) GetNode(_ context.Context, id string) (models.ProbeNode, err
return models.ProbeNode{}, fmt.Errorf("not found") return models.ProbeNode{}, fmt.Errorf("not found")
} }
func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.MaintenanceWindow, error) { func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return m.maintWindows, nil return m.maintWindows, nil
} }
@@ -141,7 +188,7 @@ func authReq(method, url, secret string, body []byte) (*http.Response, error) {
return nil, err return nil, err
} }
if secret != "" { if secret != "" {
req.Header.Set("X-Uptop-Secret", secret) req.Header.Set("X-Upkeep-Secret", secret)
} }
return http.DefaultClient.Do(req) return http.DefaultClient.Do(req)
} }
@@ -252,7 +299,7 @@ func TestExport_Unauthorized_WrongKey(t *testing.T) {
func TestExport_Success(t *testing.T) { func TestExport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
ts.store.sites = []models.SiteConfig{{ID: 1, Name: "example", URL: "http://example.com"}} ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}}
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil) resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
if err != nil { if err != nil {
@@ -299,7 +346,7 @@ func TestImport_Unauthorized(t *testing.T) {
func TestImport_Success(t *testing.T) { func TestImport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false) ts := newTestServer(t, "secret", false)
backup := models.Backup{ backup := models.Backup{
Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}}, Sites: []models.Site{{Name: "imported", URL: "http://example.com"}},
} }
body, _ := json.Marshal(backup) body, _ := json.Marshal(backup)
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body) resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
@@ -429,32 +476,15 @@ func TestStatusPage_Enabled(t *testing.T) {
} }
} }
func TestStatusJSON_PublicDTOOnly(t *testing.T) { func TestStatusJSON_TokensStripped(t *testing.T) {
ts := newTestServer(t, "secret", true) ts := newTestServer(t, "secret", true)
// Seed a push monitor (no network IO) through the store and start the // Inject a site with a token into engine state
// engine so its poll loop loads it into live state — the path real sites ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"})
// take. The old version of this test injected via UpdateSiteConfig, which // Need to inject directly since UpdateSiteConfig only updates existing
// no-ops for unknown IDs, so it asserted over zero sites and passed func() {
// against a server that leaked tokens. ts.engine.RecordHeartbeat("unused") // just to exercise, won't match
ts.store.sites = []models.SiteConfig{{ }()
ID: 1, Name: "test", Type: "push", Token: "secret-token",
Hostname: "internal-host", AlertID: 3,
}}
ctx, cancel := context.WithCancel(context.Background())
ts.engine.Start(ctx)
t.Cleanup(func() {
cancel()
ts.engine.Stop()
})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && len(ts.engine.GetLiveState()) == 0 {
time.Sleep(10 * time.Millisecond)
}
if len(ts.engine.GetLiveState()) == 0 {
t.Fatal("engine never loaded the seeded site")
}
resp, err := http.Get(ts.baseURL + "/status/json") resp, err := http.Get(ts.baseURL + "/status/json")
if err != nil { if err != nil {
@@ -464,23 +494,11 @@ func TestStatusJSON_PublicDTOOnly(t *testing.T) {
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode) t.Errorf("expected 200, got %d", resp.StatusCode)
} }
var state map[string]models.Site
// Decode raw so absent struct fields can't mask leaked JSON keys. json.NewDecoder(resp.Body).Decode(&state)
var state map[string]map[string]any
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
t.Fatal(err)
}
if len(state) != 1 {
t.Fatalf("expected 1 site in status JSON, got %d", len(state))
}
for _, site := range state { for _, site := range state {
if site["Name"] != "test" { if site.Token != "" {
t.Errorf("expected Name to be public, got %v", site["Name"]) t.Error("expected token stripped from status JSON response")
}
for _, leaked := range []string{"Token", "LastError", "Hostname", "Port", "DNSServer", "AlertID", "AcceptedCodes", "Interval"} {
if _, ok := site[leaked]; ok {
t.Errorf("status JSON leaks internal field %q", leaked)
}
} }
} }
} }
@@ -543,108 +561,3 @@ func TestProbeAssignments_Unauthorized(t *testing.T) {
t.Errorf("expected 401, got %d", resp.StatusCode) t.Errorf("expected 401, got %d", resp.StatusCode)
} }
} }
// --- Security: X-Forwarded-For trusted-proxy handling ---
func mustCIDR(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func TestClientIP_TrustedProxyHandling(t *testing.T) {
trusted := []*net.IPNet{mustCIDR(t, "10.0.0.0/8")}
tests := []struct {
name string
remoteAddr string
xff string
trusted []*net.IPNet
want string
}{
{"no trusted proxies ignores XFF", "203.0.113.9:5000", "1.2.3.4", nil, "203.0.113.9"},
{"untrusted peer ignores XFF", "203.0.113.9:5000", "1.2.3.4", trusted, "203.0.113.9"},
{"trusted peer honors XFF", "10.0.0.5:5000", "1.2.3.4", trusted, "1.2.3.4"},
{"trusted peer, rightmost-untrusted hop", "10.0.0.5:5000", "1.2.3.4, 10.0.0.9", trusted, "1.2.3.4"},
{"trusted peer, no XFF falls back to peer", "10.0.0.5:5000", "", trusted, "10.0.0.5"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, _ := http.NewRequest(http.MethodGet, "/", nil)
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, _ := http.NewRequest(http.MethodGet, "/", nil)
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])
}
}
})
}
}
+1 -8
View File
@@ -5,20 +5,13 @@ import (
"strconv" "strconv"
) )
type Migration struct {
Version int
SQL string
}
type Dialect interface { type Dialect interface {
DriverName() string DriverName() string
CreateTablesSQL() []string CreateTablesSQL() []string
Migrations() []Migration MigrationsSQL() []string
BaselineVersion() int
BoolFalse() string BoolFalse() string
ResetSequenceOnEmpty(db *sql.DB, table string) ResetSequenceOnEmpty(db *sql.DB, table string)
ImportWipe(tx *sql.Tx) ImportWipe(tx *sql.Tx)
ImportWipeUsers(tx *sql.Tx)
ImportResetSequences(tx *sql.Tx) ImportResetSequences(tx *sql.Tx)
UpsertNodeSQL() string UpsertNodeSQL() string
UpsertAlertHealthSQL() string UpsertAlertHealthSQL() string
+35 -58
View File
@@ -2,7 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log/slog" "log"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -15,7 +15,6 @@ func NewPostgresStore(connStr string) (*SQLStore, error) {
func (d *PostgresDialect) DriverName() string { return "postgres" } func (d *PostgresDialect) DriverName() string { return "postgres" }
func (d *PostgresDialect) BoolFalse() string { return "FALSE" } func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
func (d *PostgresDialect) BaselineVersion() int { return 21 }
func (d *PostgresDialect) CreateTablesSQL() []string { func (d *PostgresDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -33,8 +32,7 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE, ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE
regions TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -44,21 +42,20 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
site_id INTEGER NOT NULL, latency_ns BIGINT, site_id INTEGER NOT NULL, latency_ns BIGINT,
is_up BOOLEAN, checked_at TIMESTAMPTZ DEFAULT NOW(), is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW()
node_id TEXT DEFAULT ''
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
region TEXT DEFAULT '', region TEXT DEFAULT '',
last_seen TIMESTAMPTZ DEFAULT NOW(), last_seen TIMESTAMP DEFAULT NOW(),
version TEXT DEFAULT '' version TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS logs ( `CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
message TEXT NOT NULL, message TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS maintenance_windows ( `CREATE TABLE IF NOT EXISTS maintenance_windows (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -66,10 +63,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT DEFAULT '', description TEXT DEFAULT '',
type TEXT DEFAULT 'maintenance', type TEXT DEFAULT 'maintenance',
start_time TIMESTAMPTZ NOT NULL, start_time TIMESTAMP NOT NULL,
end_time TIMESTAMPTZ, end_time TIMESTAMP,
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences ( `CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -81,12 +78,12 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
from_status TEXT NOT NULL, from_status TEXT NOT NULL,
to_status TEXT NOT NULL, to_status TEXT NOT NULL,
error_reason TEXT DEFAULT '', error_reason TEXT DEFAULT '',
changed_at TIMESTAMPTZ DEFAULT NOW() changed_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
`CREATE TABLE IF NOT EXISTS alert_health ( `CREATE TABLE IF NOT EXISTS alert_health (
alert_id INTEGER PRIMARY KEY, alert_id INTEGER PRIMARY KEY,
last_send_at TIMESTAMPTZ, last_send_at TIMESTAMP,
last_send_ok BOOLEAN DEFAULT FALSE, last_send_ok BOOLEAN DEFAULT FALSE,
last_error TEXT DEFAULT '', last_error TEXT DEFAULT '',
send_count INTEGER DEFAULT 0, send_count INTEGER DEFAULT 0,
@@ -95,29 +92,21 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
} }
} }
func (d *PostgresDialect) Migrations() []Migration { func (d *PostgresDialect) MigrationsSQL() []string {
return []Migration{ return []string{
{1, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
{2, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
{3, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0",
{4, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS method TEXT DEFAULT 'GET'",
{5, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS description TEXT DEFAULT ''",
{6, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS parent_id INTEGER DEFAULT 0",
{7, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS accepted_codes TEXT DEFAULT '200-299'",
{8, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''",
{9, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''",
{10, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE",
{11, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE",
{12, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''"}, "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''",
{13, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''",
{14, "ALTER TABLE check_history ALTER COLUMN checked_at TYPE TIMESTAMPTZ USING checked_at AT TIME ZONE 'UTC'"},
{15, "ALTER TABLE nodes ALTER COLUMN last_seen TYPE TIMESTAMPTZ USING last_seen AT TIME ZONE 'UTC'"},
{16, "ALTER TABLE logs ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"},
{17, "ALTER TABLE maintenance_windows ALTER COLUMN start_time TYPE TIMESTAMPTZ USING start_time AT TIME ZONE 'UTC'"},
{18, "ALTER TABLE maintenance_windows ALTER COLUMN end_time TYPE TIMESTAMPTZ USING end_time AT TIME ZONE 'UTC'"},
{19, "ALTER TABLE maintenance_windows ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'"},
{20, "ALTER TABLE state_changes ALTER COLUMN changed_at TYPE TIMESTAMPTZ USING changed_at AT TIME ZONE 'UTC'"},
{21, "ALTER TABLE alert_health ALTER COLUMN last_send_at TYPE TIMESTAMPTZ USING last_send_at AT TIME ZONE 'UTC'"},
} }
} }
@@ -133,42 +122,30 @@ func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "sites", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "alerts", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil {
log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil { if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE check_history RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "check_history", "err", err)
}
if _, err := tx.Exec("TRUNCATE TABLE state_changes RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "state_changes", "err", err)
}
if _, err := tx.Exec("TRUNCATE TABLE alert_health RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "alert_health", "err", err)
}
}
func (d *PostgresDialect) ImportWipeUsers(tx *sql.Tx) {
if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil {
slog.Debug("import wipe failed", "table", "users", "err", err)
} }
} }
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil { if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil {
slog.Debug("sequence reset failed", "table", "sites", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil { if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil {
slog.Debug("sequence reset failed", "table", "alerts", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil { if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil {
slog.Debug("sequence reset failed", "table", "users", "err", err) log.Printf("sequence reset error: %v", err)
} }
if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil { if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil {
slog.Debug("sequence reset failed", "table", "maintenance_windows", "err", err) log.Printf("sequence reset error: %v", err)
} }
} }
+36 -67
View File
@@ -2,43 +2,26 @@ package store
import ( import (
"database/sql" "database/sql"
"fmt" "log"
"log/slog"
"os"
_ "modernc.org/sqlite" _ "github.com/mattn/go-sqlite3"
) )
type SQLiteDialect struct{} type SQLiteDialect struct{}
func NewSQLiteStore(path string) (*SQLStore, error) { func NewSQLiteStore(path string) (*SQLStore, error) {
// Apply pragmas via the DSN so every pooled connection gets them — a s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
// post-open PRAGMA Exec only affects a single connection. WAL allows
// concurrent readers alongside the single writer goroutine; busy_timeout
// rides out brief lock contention; synchronous=NORMAL is durable under WAL
// and far faster than the FULL default. (:memory: is left untouched —
// these pragmas are no-ops or harmful for the in-memory test DB.)
dsn := path
if path != ":memory:" {
dsn = fmt.Sprintf("file:%s?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)&_pragma=synchronous(normal)", path)
}
s, err := NewSQLStore("sqlite", dsn, &SQLiteDialect{})
if err != nil { if err != nil {
return nil, err return nil, err
} }
if path != ":memory:" { if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
for _, suffix := range []string{"", "-wal", "-shm"} { log.Printf("WAL mode failed: %v", err)
if err := os.Chmod(path+suffix, 0600); err != nil && !os.IsNotExist(err) {
slog.Warn("failed to chmod database file", "path", path+suffix, "err", err)
}
}
} }
return s, nil return s, nil
} }
func (d *SQLiteDialect) DriverName() string { return "sqlite" } func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
func (d *SQLiteDialect) BoolFalse() string { return "0" } func (d *SQLiteDialect) BoolFalse() string { return "0" }
func (d *SQLiteDialect) BaselineVersion() int { return 13 }
func (d *SQLiteDialect) CreateTablesSQL() []string { func (d *SQLiteDialect) CreateTablesSQL() []string {
return []string{ return []string{
@@ -56,8 +39,7 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
method TEXT DEFAULT 'GET', description TEXT DEFAULT '', method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299', parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '', dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0, ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0
regions TEXT DEFAULT ''
)`, )`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -67,8 +49,7 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
`CREATE TABLE IF NOT EXISTS check_history ( `CREATE TABLE IF NOT EXISTS check_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL, latency_ns INTEGER, site_id INTEGER NOT NULL, latency_ns INTEGER,
is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
node_id TEXT DEFAULT ''
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes ( `CREATE TABLE IF NOT EXISTS nodes (
@@ -118,21 +99,21 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
} }
} }
func (d *SQLiteDialect) Migrations() []Migration { func (d *SQLiteDialect) MigrationsSQL() []string {
return []Migration{ return []string{
{1, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''",
{2, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0",
{3, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0",
{4, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'"}, "ALTER TABLE sites ADD COLUMN method TEXT DEFAULT 'GET'",
{5, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN description TEXT DEFAULT ''",
{6, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN parent_id INTEGER DEFAULT 0",
{7, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'"}, "ALTER TABLE sites ADD COLUMN accepted_codes TEXT DEFAULT '200-299'",
{8, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''",
{9, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''",
{10, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0",
{11, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0"}, "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0",
{12, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''"}, "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''",
{13, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''"}, "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''",
} }
} }
@@ -149,47 +130,35 @@ func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
if count == 0 { if count == 0 {
if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil { if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil {
slog.Debug("sequence cleanup failed", "table", table, "err", err) log.Printf("sequence cleanup error: %v", err)
} }
} }
} }
func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) { func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
if _, err := tx.Exec("DELETE FROM sites"); err != nil { if _, err := tx.Exec("DELETE FROM sites"); err != nil {
slog.Debug("import wipe failed", "table", "sites", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(sites)", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM alerts"); err != nil { if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
slog.Debug("import wipe failed", "table", "alerts", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(alerts)", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil {
slog.Debug("import wipe failed", "table", "maintenance_windows", "err", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(maintenance_windows)", "err", err)
}
if _, err := tx.Exec("DELETE FROM check_history"); err != nil {
slog.Debug("import wipe failed", "table", "check_history", "err", err)
}
if _, err := tx.Exec("DELETE FROM state_changes"); err != nil {
slog.Debug("import wipe failed", "table", "state_changes", "err", err)
}
if _, err := tx.Exec("DELETE FROM alert_health"); err != nil {
slog.Debug("import wipe failed", "table", "alert_health", "err", err)
}
}
func (d *SQLiteDialect) ImportWipeUsers(tx *sql.Tx) {
if _, err := tx.Exec("DELETE FROM users"); err != nil { if _, err := tx.Exec("DELETE FROM users"); err != nil {
slog.Debug("import wipe failed", "table", "users", "err", err) log.Printf("import wipe error: %v", err)
} }
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil { if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil {
slog.Debug("import wipe failed", "table", "sqlite_sequence(users)", "err", err) log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM maintenance_windows"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'"); err != nil {
log.Printf("import wipe error: %v", err)
} }
} }
+131 -215
View File
@@ -1,12 +1,12 @@
package store package store
import ( import (
"context"
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
@@ -14,9 +14,9 @@ import (
const ( const (
maxCheckHistory = 1000 maxCheckHistory = 1000
maxLogRows = 200 checkHistoryPruneAt = 1100
maxStateChangesPerSite = 5000
maxMaintenanceExport = 1000 maxMaintenanceExport = 1000
maxRequestBody = 1 << 20
) )
type SQLStore struct { type SQLStore struct {
@@ -72,59 +72,38 @@ func (s *SQLStore) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *SQLStore) Init(ctx context.Context) error { func (s *SQLStore) Init() error {
for _, stmt := range s.dialect.CreateTablesSQL() { for _, stmt := range s.dialect.CreateTablesSQL() {
if _, err := s.db.ExecContext(ctx, stmt); err != nil { if _, err := s.db.Exec(stmt); err != nil {
return err return err
} }
} }
for _, m := range s.dialect.MigrationsSQL() {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version ( if _, err := s.db.Exec(m); err != nil {
version INTEGER PRIMARY KEY, errMsg := err.Error()
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
)`); err != nil {
return fmt.Errorf("create schema_version: %w", err)
}
var current int
_ = s.db.QueryRowContext(ctx, "SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&current) //nolint:errcheck
if current == 0 {
baseline := s.dialect.BaselineVersion()
if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), baseline); err != nil {
return fmt.Errorf("seed baseline version: %w", err)
}
current = baseline
}
for _, m := range s.dialect.Migrations() {
if m.Version <= current {
continue continue
} }
if _, err := s.db.ExecContext(ctx, m.SQL); err != nil { return fmt.Errorf("migration failed: %w", err)
return fmt.Errorf("migration %d failed: %w", m.Version, err)
}
if _, err := s.db.ExecContext(ctx, s.q("INSERT INTO schema_version (version) VALUES (?)"), m.Version); err != nil {
return fmt.Errorf("record migration %d: %w", m.Version, err)
} }
} }
return nil return nil
} }
func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) { func (s *SQLStore) GetSites() ([]models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites", "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites",
bf, bf, bf, bf,
) )
rows, err := s.db.QueryContext(ctx, query) rows, err := s.db.Query(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var sites []models.SiteConfig var sites []models.Site
for rows.Next() { for rows.Next() {
var st models.SiteConfig var st models.Site
if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
@@ -136,7 +115,7 @@ func (s *SQLStore) GetSites(ctx context.Context) ([]models.SiteConfig, error) {
return sites, rows.Err() return sites, rows.Err()
} }
func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error { func (s *SQLStore) AddSite(site models.Site) error {
token := "" token := ""
if site.Type == "push" { if site.Type == "push" {
var err error var err error
@@ -145,17 +124,15 @@ func (s *SQLStore) AddSite(ctx context.Context, site models.SiteConfig) error {
return fmt.Errorf("generate push token: %w", err) return fmt.Errorf("generate push token: %w", err)
} }
} }
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), _, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
return err return err
} }
func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error { func (s *SQLStore) UpdateSite(site models.Site) error {
var existingToken string var existingToken string
if err := s.db.QueryRowContext(ctx, s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken); err != nil && err != sql.ErrNoRows { _ = s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck
return fmt.Errorf("read existing token: %w", err)
}
if site.Type == "push" && existingToken == "" { if site.Type == "push" && existingToken == "" {
var err error var err error
existingToken, err = generateToken() existingToken, err = generateToken()
@@ -163,50 +140,34 @@ func (s *SQLStore) UpdateSite(ctx context.Context, site models.SiteConfig) error
return fmt.Errorf("generate push token: %w", err) return fmt.Errorf("generate push token: %w", err)
} }
} }
_, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"), _, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"),
site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID)
return err return err
} }
func (s *SQLStore) UpdateSitePaused(ctx context.Context, id int, paused bool) error { func (s *SQLStore) UpdateSitePaused(id int, paused bool) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id) _, err := s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id)
return err return err
} }
func (s *SQLStore) DeleteSite(ctx context.Context, id int) error { func (s *SQLStore) DeleteSite(id int) error {
tx, err := s.db.BeginTx(ctx, nil) _, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = tx.Rollback() }()
for _, q := range []string{
"DELETE FROM maintenance_windows WHERE monitor_id = ?",
"DELETE FROM check_history WHERE site_id = ?",
"DELETE FROM state_changes WHERE site_id = ?",
"DELETE FROM sites WHERE id = ?",
} {
if _, err := tx.ExecContext(ctx, s.q(q), id); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "sites") s.dialect.ResetSequenceOnEmpty(s.db, "sites")
return nil return nil
} }
func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input query := fmt.Sprintf( //nolint:gosec // bf is a dialect boolean literal, not user input
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s", "SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s",
bf, bf, s.q("?"), bf, bf, s.q("?"),
) )
var st models.SiteConfig var st models.Site
err := s.db.QueryRowContext(ctx, query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, err := s.db.QueryRow(query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
&st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions) &st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions)
@@ -233,10 +194,10 @@ func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
return s.encryptSettings(string(jsonBytes)) return s.encryptSettings(string(jsonBytes))
} }
func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) { func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig var a models.AlertConfig
var settingsRaw string 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) 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 { if err != nil {
return a, err return a, err
} }
@@ -247,7 +208,7 @@ func (s *SQLStore) GetAlertByName(ctx context.Context, name string) (models.Aler
return a, nil return a, nil
} }
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) { func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
token := "" token := ""
if site.Type == "push" { if site.Type == "push" {
var err error var err error
@@ -258,12 +219,12 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
} }
if s.dollar { if s.dollar {
var id int var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"), err := s.db.QueryRow(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id)
return id, err return id, err
} }
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), result, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries, site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions) site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
if err != nil { if err != nil {
@@ -273,17 +234,17 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
return int(id), err return int(id), err
} }
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) { func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
stored, err := s.marshalSettings(settings) stored, err := s.marshalSettings(settings)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if s.dollar { if s.dollar {
var id int var id int
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id) err := s.db.QueryRow(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err return id, err
} }
result, err := s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored) result, err := s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -291,8 +252,8 @@ func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string,
return int(id), err return int(id), err
} }
func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) { func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts") rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -313,10 +274,10 @@ func (s *SQLStore) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, erro
return alerts, rows.Err() return alerts, rows.Err()
} }
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) { func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig var a models.AlertConfig
var settingsRaw string 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) 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 { if err != nil {
return a, err return a, err
} }
@@ -327,37 +288,35 @@ func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, er
return a, nil return a, nil
} }
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error { func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings) stored, err := s.marshalSettings(settings)
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored) _, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err return err
} }
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error { func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings) stored, err := s.marshalSettings(settings)
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id) _, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err return err
} }
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error { func (s *SQLStore) DeleteAlert(id int) error {
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil { _, err := s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id)
return err if err != nil {
}
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
return err return err
} }
s.dialect.ResetSequenceOnEmpty(s.db, "alerts") s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil return nil
} }
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) { func (s *SQLStore) GetAllUsers() ([]models.User, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users") rows, err := s.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -373,29 +332,29 @@ func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
return users, rows.Err() return users, rows.Err()
} }
func (s *SQLStore) AddUser(ctx context.Context, username, publicKey, role string) error { func (s *SQLStore) AddUser(username, publicKey, role string) error {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role) _, err := s.db.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role)
return err return err
} }
func (s *SQLStore) UpdateUser(ctx context.Context, id int, username, publicKey, role string) error { func (s *SQLStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id) _, err := s.db.Exec(s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id)
return err return err
} }
func (s *SQLStore) DeleteUser(ctx context.Context, id int) error { func (s *SQLStore) DeleteUser(id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM users WHERE id=?"), id) _, err := s.db.Exec(s.q("DELETE FROM users WHERE id=?"), id)
return err return err
} }
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error { func (s *SQLStore) SaveStateChange(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 (?, ?, ?, ?)"), _, err := s.db.Exec(s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
siteID, fromStatus, toStatus, errorReason) siteID, fromStatus, toStatus, errorReason)
return err return err
} }
func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) { func (s *SQLStore) GetStateChanges(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) rows, err := s.db.Query(s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -411,8 +370,8 @@ func (s *SQLStore) GetStateChanges(ctx context.Context, siteID int, limit int) (
return changes, rows.Err() return changes, rows.Err()
} }
func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) { func (s *SQLStore) GetStateChangesSince(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) rows, err := s.db.Query(s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -428,59 +387,41 @@ func (s *SQLStore) GetStateChangesSince(ctx context.Context, siteID int, since t
return changes, rows.Err() return changes, rows.Err()
} }
func (s *SQLStore) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error { func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp) return s.SaveCheckFromNode(siteID, "", latencyNs, isUp)
} }
// SaveCheckFromNode inserts a single check row. Retention is handled out of func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot _, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
// path a plain INSERT. if err != nil {
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 return err
} }
var count int
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per _ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
// site, across all sites, in one pass. Intended to run periodically. if count > checkHistoryPruneAt {
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error { pruneQuery := fmt.Sprintf(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN ( SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT %d
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) )`, maxCheckHistory)
_, err := s.db.ExecContext(ctx, s.q(q)) _, err = s.db.Exec(s.q(pruneQuery), siteID, siteID)
return err
}
return nil
}
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
_, err := s.db.Exec(s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
return err return err
} }
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite func (s *SQLStore) GetNode(id string) (models.ProbeNode, error) {
// 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) 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(ctx context.Context, id string) (models.ProbeNode, error) {
var n models.ProbeNode var n models.ProbeNode
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id). err := s.db.QueryRow(s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id).
Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version) Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version)
return n, err return n, err
} }
func (s *SQLStore) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) { func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) {
rows, err := s.db.QueryContext(ctx, "SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name") rows, err := s.db.Query("SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -496,18 +437,18 @@ func (s *SQLStore) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error)
return nodes, rows.Err() return nodes, rows.Err()
} }
func (s *SQLStore) UpdateNodeLastSeen(ctx context.Context, id string) error { func (s *SQLStore) UpdateNodeLastSeen(id string) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id) _, err := s.db.Exec(s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err return err
} }
func (s *SQLStore) DeleteNode(ctx context.Context, id string) error { func (s *SQLStore) DeleteNode(id string) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM nodes WHERE id = ?"), id) _, err := s.db.Exec(s.q("DELETE FROM nodes WHERE id = ?"), id)
return err return err
} }
func (s *SQLStore) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) { func (s *SQLStore) LoadAlertHealth() (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") rows, err := s.db.Query("SELECT alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count FROM alert_health")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -527,35 +468,29 @@ func (s *SQLStore) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHea
return out, rows.Err() return out, rows.Err()
} }
func (s *SQLStore) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error { func (s *SQLStore) SaveAlertHealth(h models.AlertHealthRecord) error {
var lastSend interface{} var lastSend interface{}
if !h.LastSendAt.IsZero() { if !h.LastSendAt.IsZero() {
lastSend = h.LastSendAt lastSend = h.LastSendAt
} }
_, err := s.db.ExecContext(ctx, s.dialect.UpsertAlertHealthSQL(), _, err := s.db.Exec(s.dialect.UpsertAlertHealthSQL(),
h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount) h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount)
return err return err
} }
// SaveLog inserts a single log row. Retention is handled by PruneLogs on a func (s *SQLStore) SaveLog(message string) error {
// timer, not per-insert. _, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
func (s *SQLStore) SaveLog(ctx context.Context, message string) error { if err != nil {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO logs (message) VALUES (?)"), message) 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 return err
} }
// PruneLogs trims the logs table to the newest maxLogRows rows. The id DESC func (s *SQLStore) LoadLogs(limit int) ([]string, error) {
// tiebreak keeps ordering deterministic when rows share a created_at second. rows, err := s.db.Query(s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
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) ([]string, error) {
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -571,9 +506,9 @@ func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
return logs, rows.Err() return logs, rows.Err()
} }
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) { func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) {
result := make(map[int][]models.CheckRecord) result := make(map[int][]models.CheckRecord)
rows, err := s.db.QueryContext(ctx, s.q(` rows, err := s.db.Query(s.q(`
SELECT site_id, latency_ns, is_up FROM ( SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up, SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
@@ -611,8 +546,8 @@ func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWind
return mw, nil return mw, nil
} }
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) { func (s *SQLStore) GetActiveMaintenanceWindows() ([]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")) 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"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -628,8 +563,8 @@ func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.Ma
return windows, rows.Err() return windows, rows.Err()
} }
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) { func (s *SQLStore) GetAllMaintenanceWindows(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) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -645,22 +580,22 @@ func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m
return windows, rows.Err() return windows, rows.Err()
} }
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error { func (s *SQLStore) AddMaintenanceWindow(mw models.MaintenanceWindow) error {
if mw.StartTime.IsZero() { if mw.StartTime.IsZero() {
mw.StartTime = time.Now() 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 (?, ?, ?, ?, ?, ?, ?)"), _, 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) mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
return err return err
} }
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error { func (s *SQLStore) EndMaintenanceWindow(id int) error {
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id) _, err := s.db.Exec(s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err return err
} }
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error { func (s *SQLStore) DeleteMaintenanceWindow(id int) error {
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id) _, err := s.db.Exec(s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
if err != nil { if err != nil {
return err return err
} }
@@ -668,21 +603,9 @@ func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
return nil return nil
} }
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) { func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, 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 var count int
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM maintenance_windows
WHERE type = 'maintenance' WHERE type = 'maintenance'
AND start_time <= CURRENT_TIMESTAMP AND start_time <= CURRENT_TIMESTAMP
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
@@ -695,46 +618,46 @@ func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (b
return count > 0, nil return count > 0, nil
} }
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) { func (s *SQLStore) GetPreference(key string) (string, error) {
var value string var value string
err := s.db.QueryRowContext(ctx, s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value) err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
if err != nil { if err != nil {
return "", err return "", err
} }
return value, nil return value, nil
} }
func (s *SQLStore) SetPreference(ctx context.Context, key, value string) error { func (s *SQLStore) SetPreference(key, value string) error {
if s.dollar { if s.dollar {
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value) _, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
return err return err
} }
_, err := s.db.ExecContext(ctx, "INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value) _, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
return err return err
} }
func (s *SQLStore) ExportData(ctx context.Context) (models.Backup, error) { func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites(ctx) sites, err := s.GetSites()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
alerts, err := s.GetAllAlerts(ctx) alerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
users, err := s.GetAllUsers(ctx) users, err := s.GetAllUsers()
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
windows, err := s.GetAllMaintenanceWindows(ctx, maxMaintenanceExport) windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
} }
func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error { func (s *SQLStore) ImportData(data models.Backup) error {
tx, err := s.db.BeginTx(ctx, nil) tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return err
} }
@@ -742,29 +665,22 @@ func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error {
s.dialect.ImportWipe(tx) s.dialect.ImportWipe(tx)
// Only wipe+replace users when callers explicitly provide them (CLI
// full restore). API/Kuma imports pass nil — existing users preserved.
if data.Users != nil {
s.dialect.ImportWipeUsers(tx)
for _, u := range data.Users { for _, u := range data.Users {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil { if _, err := tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil {
return err return err
} }
} }
}
for _, a := range data.Alerts { for _, a := range data.Alerts {
// Encrypt on import exactly as AddAlert/UpdateAlert do, so a restore jsonBytes, err := json.Marshal(a.Settings)
// honors UPTOP_ENCRYPTION_KEY instead of writing secrets in plaintext.
settingsStr, err := s.marshalSettings(a.Settings)
if err != nil { if err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, settingsStr); err != nil { if _, err := tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)); err != nil {
return err return err
} }
} }
for _, st := range data.Sites { for _, st := range data.Sites {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries, st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil { st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil {
return err return err
@@ -772,7 +688,7 @@ func (s *SQLStore) ImportData(ctx context.Context, data models.Backup) error {
} }
for _, mw := range data.MaintenanceWindows { for _, mw := range data.MaintenanceWindows {
if _, err := tx.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"), if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil { mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil {
return err return err
} }
+36 -354
View File
@@ -1,13 +1,8 @@
package store package store
import ( import (
"context"
"fmt"
"strings"
"testing"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"testing"
) )
func newTestStore(t *testing.T) *SQLStore { func newTestStore(t *testing.T) *SQLStore {
@@ -16,7 +11,7 @@ func newTestStore(t *testing.T) *SQLStore {
if err != nil { if err != nil {
t.Fatalf("NewSQLiteStore: %v", err) t.Fatalf("NewSQLiteStore: %v", err)
} }
if err := s.Init(context.Background()); err != nil { if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err) t.Fatalf("Init: %v", err)
} }
return s return s
@@ -25,7 +20,7 @@ func newTestStore(t *testing.T) *SQLStore {
func TestSiteCRUD(t *testing.T) { func TestSiteCRUD(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
sites, err := s.GetSites(context.Background()) sites, err := s.GetSites()
if err != nil { if err != nil {
t.Fatalf("GetSites: %v", err) t.Fatalf("GetSites: %v", err)
} }
@@ -33,11 +28,11 @@ func TestSiteCRUD(t *testing.T) {
t.Fatalf("expected 0 sites, got %d", len(sites)) t.Fatalf("expected 0 sites, got %d", len(sites))
} }
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil { if err := s.AddSite(models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
t.Fatalf("AddSite: %v", err) t.Fatalf("AddSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, err = s.GetSites()
if err != nil { if err != nil {
t.Fatalf("GetSites: %v", err) t.Fatalf("GetSites: %v", err)
} }
@@ -49,26 +44,20 @@ func TestSiteCRUD(t *testing.T) {
} }
sites[0].Name = "Updated" sites[0].Name = "Updated"
if err := s.UpdateSite(context.Background(), sites[0]); err != nil { if err := s.UpdateSite(sites[0]); err != nil {
t.Fatalf("UpdateSite: %v", err) t.Fatalf("UpdateSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, _ = s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if sites[0].Name != "Updated" { if sites[0].Name != "Updated" {
t.Errorf("expected name 'Updated', got '%s'", sites[0].Name) t.Errorf("expected name 'Updated', got '%s'", sites[0].Name)
} }
if err := s.DeleteSite(context.Background(), sites[0].ID); err != nil { if err := s.DeleteSite(sites[0].ID); err != nil {
t.Fatalf("DeleteSite: %v", err) t.Fatalf("DeleteSite: %v", err)
} }
sites, err = s.GetSites(context.Background()) sites, _ = s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 0 { if len(sites) != 0 {
t.Fatalf("expected 0 sites after delete, got %d", len(sites)) t.Fatalf("expected 0 sites after delete, got %d", len(sites))
} }
@@ -77,11 +66,11 @@ func TestSiteCRUD(t *testing.T) {
func TestAlertCRUD(t *testing.T) { func TestAlertCRUD(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil { if err := s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err) t.Fatalf("AddAlert: %v", err)
} }
alerts, err := s.GetAllAlerts(context.Background()) alerts, err := s.GetAllAlerts()
if err != nil { if err != nil {
t.Fatalf("GetAllAlerts: %v", err) t.Fatalf("GetAllAlerts: %v", err)
} }
@@ -95,7 +84,7 @@ func TestAlertCRUD(t *testing.T) {
t.Errorf("settings url mismatch") t.Errorf("settings url mismatch")
} }
a, err := s.GetAlert(context.Background(), alerts[0].ID) a, err := s.GetAlert(alerts[0].ID)
if err != nil { if err != nil {
t.Fatalf("GetAlert: %v", err) t.Fatalf("GetAlert: %v", err)
} }
@@ -103,26 +92,20 @@ func TestAlertCRUD(t *testing.T) {
t.Errorf("expected name 'Discord', got '%s'", a.Name) 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 { if err := s.UpdateAlert(a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err) t.Fatalf("UpdateAlert: %v", err)
} }
a, err = s.GetAlert(context.Background(), a.ID) a, _ = s.GetAlert(a.ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Type != "slack" { if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type) t.Errorf("expected type 'slack', got '%s'", a.Type)
} }
if err := s.DeleteAlert(context.Background(), a.ID); err != nil { if err := s.DeleteAlert(a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err) t.Fatalf("DeleteAlert: %v", err)
} }
alerts, err = s.GetAllAlerts(context.Background()) alerts, _ = s.GetAllAlerts()
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 0 { if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts)) t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
} }
@@ -131,11 +114,11 @@ func TestAlertCRUD(t *testing.T) {
func TestUserCRUD(t *testing.T) { func TestUserCRUD(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 AAAA...", "admin"); err != nil { if err := s.AddUser("admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
t.Fatalf("AddUser: %v", err) t.Fatalf("AddUser: %v", err)
} }
users, err := s.GetAllUsers(context.Background()) users, err := s.GetAllUsers()
if err != nil { if err != nil {
t.Fatalf("GetAllUsers: %v", err) t.Fatalf("GetAllUsers: %v", err)
} }
@@ -146,26 +129,20 @@ func TestUserCRUD(t *testing.T) {
t.Errorf("expected username 'admin', got '%s'", users[0].Username) t.Errorf("expected username 'admin', got '%s'", users[0].Username)
} }
if err := s.UpdateUser(context.Background(), users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil { if err := s.UpdateUser(users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
t.Fatalf("UpdateUser: %v", err) t.Fatalf("UpdateUser: %v", err)
} }
users, err = s.GetAllUsers(context.Background()) users, _ = s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if users[0].Username != "root" { if users[0].Username != "root" {
t.Errorf("expected username 'root', got '%s'", users[0].Username) t.Errorf("expected username 'root', got '%s'", users[0].Username)
} }
if err := s.DeleteUser(context.Background(), users[0].ID); err != nil { if err := s.DeleteUser(users[0].ID); err != nil {
t.Fatalf("DeleteUser: %v", err) t.Fatalf("DeleteUser: %v", err)
} }
users, err = s.GetAllUsers(context.Background()) users, _ = s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(users) != 0 { if len(users) != 0 {
t.Fatalf("expected 0 users after delete, got %d", len(users)) t.Fatalf("expected 0 users after delete, got %d", len(users))
} }
@@ -174,14 +151,11 @@ func TestUserCRUD(t *testing.T) {
func TestPushTokenGeneration(t *testing.T) { func TestPushTokenGeneration(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil { if err := s.AddSite(models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
t.Fatalf("AddSite: %v", err) t.Fatalf("AddSite: %v", err)
} }
sites, err := s.GetSites(context.Background()) sites, _ := s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 1 { if len(sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(sites)) t.Fatalf("expected 1 site, got %d", len(sites))
} }
@@ -196,17 +170,11 @@ func TestPushTokenGeneration(t *testing.T) {
func TestImportExport(t *testing.T) { func TestImportExport(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.AddAlert(context.Background(), "Test Alert", "webhook", map[string]string{"url": "https://example.com"}); err != nil { s.AddAlert("Test Alert", "webhook", map[string]string{"url": "https://example.com"})
t.Fatalf("AddAlert: %v", err) s.AddSite(models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30})
} s.AddUser("user1", "ssh-ed25519 KEY", "user")
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
t.Fatalf("AddSite: %v", err)
}
if err := s.AddUser(context.Background(), "user1", "ssh-ed25519 KEY", "user"); err != nil {
t.Fatalf("AddUser: %v", err)
}
backup, err := s.ExportData(context.Background()) backup, err := s.ExportData()
if err != nil { if err != nil {
t.Fatalf("ExportData: %v", err) t.Fatalf("ExportData: %v", err)
} }
@@ -215,106 +183,32 @@ func TestImportExport(t *testing.T) {
} }
s2 := newTestStore(t) s2 := newTestStore(t)
if err := s2.ImportData(context.Background(), backup); err != nil { if err := s2.ImportData(backup); err != nil {
t.Fatalf("ImportData: %v", err) t.Fatalf("ImportData: %v", err)
} }
sites, err := s2.GetSites(context.Background()) sites, _ := s2.GetSites()
if err != nil { alerts, _ := s2.GetAllAlerts()
t.Fatalf("GetSites: %v", err) users, _ := s2.GetAllUsers()
}
alerts, err := s2.GetAllAlerts(context.Background())
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
users, err := s2.GetAllUsers(context.Background())
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 { if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 {
t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users)) t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users))
} }
} }
func TestImportData_WipesHistory(t *testing.T) {
s := newTestStore(t)
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.SaveStateChange(context.Background(), 1, "UP", "DOWN", "timeout"); err != nil {
t.Fatalf("SaveStateChange: %v", err)
}
if err := s.SaveAlertHealth(context.Background(), models.AlertHealthRecord{AlertID: 1, LastSendOK: true, SendCount: 1}); err != nil {
t.Fatalf("SaveAlertHealth: %v", err)
}
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) != 0 {
t.Errorf("expected empty check_history after import, got %d sites with history", len(history))
}
changes, err := s.GetStateChanges(context.Background(), 1, 100)
if err != nil {
t.Fatalf("GetStateChanges: %v", err)
}
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 TestCheckHistory(t *testing.T) { func TestCheckHistory(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil { if err := s.SaveCheck(1, 5000000, true); err != nil {
t.Fatalf("SaveCheck: %v", err) t.Fatalf("SaveCheck: %v", err)
} }
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil { if err := s.SaveCheck(1, 10000000, false); err != nil {
t.Fatalf("SaveCheck: %v", err) t.Fatalf("SaveCheck: %v", err)
} }
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil { if err := s.SaveCheck(2, 3000000, true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err) t.Fatalf("SaveCheck site 2: %v", err)
} }
history, err := s.LoadAllHistory(context.Background(), 10) history, err := s.LoadAllHistory(10)
if err != nil { if err != nil {
t.Fatalf("LoadAllHistory: %v", err) t.Fatalf("LoadAllHistory: %v", err)
} }
@@ -335,215 +229,3 @@ func TestCheckHistory(t *testing.T) {
t.Errorf("expected 1 up record for site 1, got %d", upCount) t.Errorf("expected 1 up record for site 1, got %d", upCount)
} }
} }
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, _ := s.GetSites(context.Background())
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, _ := s.LoadAllHistory(context.Background(), 100)
if len(history[siteID]) != 0 {
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
}
changes, _ := s.GetStateChanges(context.Background(), siteID, 100)
if len(changes) != 0 {
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
}
windows, _ := s.GetActiveMaintenanceWindows(context.Background())
for _, w := range windows {
if w.MonitorID == siteID {
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
}
}
}
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] = 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")
}
}
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 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")
}
}
}
// 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)
}
}
+44 -49
View File
@@ -1,85 +1,80 @@
package store package store
import ( import (
"context"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
type Store interface { type Store interface {
Init(ctx context.Context) error Init() error
// Sites // Sites
GetSites(ctx context.Context) ([]models.SiteConfig, error) GetSites() ([]models.Site, error)
AddSite(ctx context.Context, site models.SiteConfig) error AddSite(site models.Site) error
UpdateSite(ctx context.Context, site models.SiteConfig) error UpdateSite(site models.Site) error
UpdateSitePaused(ctx context.Context, id int, paused bool) error UpdateSitePaused(id int, paused bool) error
DeleteSite(ctx context.Context, id int) error DeleteSite(id int) error
// Alerts // Alerts
GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) GetAllAlerts() ([]models.AlertConfig, error)
GetAlert(ctx context.Context, id int) (models.AlertConfig, error) GetAlert(id int) (models.AlertConfig, error)
AddAlert(ctx context.Context, name, aType string, settings map[string]string) error AddAlert(name, aType string, settings map[string]string) error
UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error UpdateAlert(id int, name, aType string, settings map[string]string) error
DeleteAlert(ctx context.Context, id int) error DeleteAlert(id int) error
// Declarative config support // Declarative config support
GetSiteByName(ctx context.Context, name string) (models.SiteConfig, error) GetSiteByName(name string) (models.Site, error)
GetAlertByName(ctx context.Context, name string) (models.AlertConfig, error) GetAlertByName(name string) (models.AlertConfig, error)
AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) AddSiteReturningID(site models.Site) (int, error)
AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) AddAlertReturningID(name, aType string, settings map[string]string) (int, error)
// Users // Users
GetAllUsers(ctx context.Context) ([]models.User, error) GetAllUsers() ([]models.User, error)
AddUser(ctx context.Context, username, publicKey, role string) error AddUser(username, publicKey, role string) error
UpdateUser(ctx context.Context, id int, username, publicKey, role string) error UpdateUser(id int, username, publicKey, role string) error
DeleteUser(ctx context.Context, id int) error DeleteUser(id int) error
// History // History
SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error SaveCheck(siteID int, latencyNs int64, isUp bool) error
SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
PruneCheckHistory(ctx context.Context) error
// State Changes // State Changes
SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error
GetStateChanges(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) GetStateChanges(siteID int, limit int) ([]models.StateChange, error)
GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) GetStateChangesSince(siteID int, since time.Time) ([]models.StateChange, error)
PruneStateChanges(ctx context.Context) error
// Nodes // Nodes
RegisterNode(ctx context.Context, node models.ProbeNode) error RegisterNode(node models.ProbeNode) error
GetNode(ctx context.Context, id string) (models.ProbeNode, error) GetNode(id string) (models.ProbeNode, error)
GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) GetAllNodes() ([]models.ProbeNode, error)
UpdateNodeLastSeen(ctx context.Context, id string) error UpdateNodeLastSeen(id string) error
DeleteNode(ctx context.Context, id string) error DeleteNode(id string) error
// Alert Health // Alert Health
LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) LoadAlertHealth() (map[int]models.AlertHealthRecord, error)
SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error SaveAlertHealth(h models.AlertHealthRecord) error
// Logs // Logs
SaveLog(ctx context.Context, message string) error SaveLog(message string) error
LoadLogs(ctx context.Context, limit int) ([]string, error) LoadLogs(limit int) ([]string, error)
PruneLogs(ctx context.Context) error
// Maintenance Windows // Maintenance Windows
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error AddMaintenanceWindow(mw models.MaintenanceWindow) error
EndMaintenanceWindow(ctx context.Context, id int) error EndMaintenanceWindow(id int) error
DeleteMaintenanceWindow(ctx context.Context, id int) error DeleteMaintenanceWindow(id int) error
PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) IsMonitorInMaintenance(monitorID int) (bool, error)
IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error)
// Preferences // Preferences
GetPreference(ctx context.Context, key string) (string, error) GetPreference(key string) (string, error)
SetPreference(ctx context.Context, key, value string) error SetPreference(key, value string) error
// Backup & Restore // Backup & Restore
ExportData(ctx context.Context) (models.Backup, error) ExportData() (models.Backup, error)
ImportData(ctx context.Context, data models.Backup) error ImportData(data models.Backup) error
// Lifecycle // Lifecycle
Close() error Close() error
-276
View File
@@ -1,276 +0,0 @@
package storetest
import (
"context"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// BaseMock implements store.Store with no-op defaults. Embed it in test-specific
// mocks and override only the methods you need via the exported Func fields or
// by shadowing the method on the embedding struct.
type BaseMock struct {
GetSitesFunc func(ctx context.Context) ([]models.SiteConfig, error)
AddSiteFunc func(ctx context.Context, site models.SiteConfig) error
UpdateSiteFunc func(ctx context.Context, site models.SiteConfig) error
GetAllAlertsFunc func(ctx context.Context) ([]models.AlertConfig, error)
GetAlertFunc func(ctx context.Context, id int) (models.AlertConfig, error)
GetAllUsersFunc func(ctx context.Context) ([]models.User, error)
GetAllNodesFunc func(ctx context.Context) ([]models.ProbeNode, error)
GetActiveMaintenanceWindowsFunc func(ctx context.Context) ([]models.MaintenanceWindow, error)
GetAllMaintenanceWindowsFunc func(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
IsMonitorInMaintenanceFunc func(ctx context.Context, id int) (bool, error)
LoadAlertHealthFunc func(ctx context.Context) (map[int]models.AlertHealthRecord, error)
LoadAllHistoryFunc func(ctx context.Context, limit int) (map[int][]models.CheckRecord, error)
SaveCheckFunc func(ctx context.Context, siteID int, latencyNs int64, isUp bool) error
SaveCheckFromNodeFunc func(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error
SaveLogFunc func(ctx context.Context, message string) error
SaveStateChangeFunc func(ctx context.Context, siteID int, from, to, reason string) error
SaveAlertHealthFunc func(ctx context.Context, h models.AlertHealthRecord) error
GetStateChangesFunc func(ctx context.Context, siteID, limit int) ([]models.StateChange, error)
GetStateChangesSinceFunc func(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error)
ExportDataFunc func(ctx context.Context) (models.Backup, error)
ImportDataFunc func(ctx context.Context, data models.Backup) error
RegisterNodeFunc func(ctx context.Context, node models.ProbeNode) error
GetNodeFunc func(ctx context.Context, id string) (models.ProbeNode, error)
GetPreferenceFunc func(ctx context.Context, key string) (string, error)
SetPreferenceFunc func(ctx context.Context, key, value string) error
}
func (m *BaseMock) Init(_ context.Context) error { return nil }
func (m *BaseMock) Close() error { return nil }
func (m *BaseMock) GetSites(ctx context.Context) ([]models.SiteConfig, error) {
if m.GetSitesFunc != nil {
return m.GetSitesFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) AddSite(ctx context.Context, site models.SiteConfig) error {
if m.AddSiteFunc != nil {
return m.AddSiteFunc(ctx, site)
}
return nil
}
func (m *BaseMock) UpdateSite(ctx context.Context, site models.SiteConfig) error {
if m.UpdateSiteFunc != nil {
return m.UpdateSiteFunc(ctx, site)
}
return nil
}
func (m *BaseMock) UpdateSitePaused(_ context.Context, _ int, _ bool) error { return nil }
func (m *BaseMock) DeleteSite(_ context.Context, _ int) error { return nil }
func (m *BaseMock) GetAllAlerts(ctx context.Context) ([]models.AlertConfig, error) {
if m.GetAllAlertsFunc != nil {
return m.GetAllAlertsFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
if m.GetAlertFunc != nil {
return m.GetAlertFunc(ctx, id)
}
return models.AlertConfig{}, nil
}
func (m *BaseMock) AddAlert(_ context.Context, _ string, _ string, _ map[string]string) error {
return nil
}
func (m *BaseMock) UpdateAlert(_ context.Context, _ int, _ string, _ string, _ map[string]string) error {
return nil
}
func (m *BaseMock) DeleteAlert(_ context.Context, _ int) error { return nil }
func (m *BaseMock) GetSiteByName(_ context.Context, _ string) (models.SiteConfig, error) {
return models.SiteConfig{}, nil
}
func (m *BaseMock) GetAlertByName(_ context.Context, _ string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *BaseMock) AddSiteReturningID(_ context.Context, _ models.SiteConfig) (int, error) {
return 0, nil
}
func (m *BaseMock) AddAlertReturningID(_ context.Context, _ string, _ string, _ map[string]string) (int, error) {
return 0, nil
}
func (m *BaseMock) GetAllUsers(ctx context.Context) ([]models.User, error) {
if m.GetAllUsersFunc != nil {
return m.GetAllUsersFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) AddUser(_ context.Context, _ string, _ string, _ string) error { return nil }
func (m *BaseMock) UpdateUser(_ context.Context, _ int, _ string, _ string, _ string) error {
return nil
}
func (m *BaseMock) DeleteUser(_ context.Context, _ int) error { return nil }
func (m *BaseMock) SaveCheck(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
if m.SaveCheckFunc != nil {
return m.SaveCheckFunc(ctx, siteID, latencyNs, isUp)
}
return nil
}
func (m *BaseMock) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
if m.SaveCheckFromNodeFunc != nil {
return m.SaveCheckFromNodeFunc(ctx, siteID, nodeID, latencyNs, isUp)
}
return nil
}
func (m *BaseMock) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
if m.LoadAllHistoryFunc != nil {
return m.LoadAllHistoryFunc(ctx, limit)
}
return nil, nil
}
func (m *BaseMock) PruneCheckHistory(_ context.Context) error { return nil }
func (m *BaseMock) SaveStateChange(ctx context.Context, siteID int, from, to, reason string) error {
if m.SaveStateChangeFunc != nil {
return m.SaveStateChangeFunc(ctx, siteID, from, to, reason)
}
return nil
}
func (m *BaseMock) GetStateChanges(ctx context.Context, siteID, limit int) ([]models.StateChange, error) {
if m.GetStateChangesFunc != nil {
return m.GetStateChangesFunc(ctx, siteID, limit)
}
return nil, nil
}
func (m *BaseMock) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
if m.GetStateChangesSinceFunc != nil {
return m.GetStateChangesSinceFunc(ctx, siteID, since)
}
return nil, nil
}
func (m *BaseMock) PruneStateChanges(_ context.Context) error { return nil }
func (m *BaseMock) RegisterNode(ctx context.Context, node models.ProbeNode) error {
if m.RegisterNodeFunc != nil {
return m.RegisterNodeFunc(ctx, node)
}
return nil
}
func (m *BaseMock) GetNode(ctx context.Context, id string) (models.ProbeNode, error) {
if m.GetNodeFunc != nil {
return m.GetNodeFunc(ctx, id)
}
return models.ProbeNode{}, nil
}
func (m *BaseMock) GetAllNodes(ctx context.Context) ([]models.ProbeNode, error) {
if m.GetAllNodesFunc != nil {
return m.GetAllNodesFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) UpdateNodeLastSeen(_ context.Context, _ string) error { return nil }
func (m *BaseMock) DeleteNode(_ context.Context, _ string) error { return nil }
func (m *BaseMock) LoadAlertHealth(ctx context.Context) (map[int]models.AlertHealthRecord, error) {
if m.LoadAlertHealthFunc != nil {
return m.LoadAlertHealthFunc(ctx)
}
return nil, nil
}
func (m *BaseMock) SaveAlertHealth(ctx context.Context, h models.AlertHealthRecord) error {
if m.SaveAlertHealthFunc != nil {
return m.SaveAlertHealthFunc(ctx, h)
}
return nil
}
func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
if m.SaveLogFunc != nil {
return m.SaveLogFunc(ctx, message)
}
return nil
}
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, 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) 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
}
+26 -105
View File
@@ -1,20 +1,17 @@
package tui package tui
import ( import (
"context"
"encoding/json" "encoding/json"
"sort" "sort"
"strings" "strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store" "gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
tea "github.com/charmbracelet/bubbletea"
) )
func loadCollapsed(s store.Store) map[int]bool { func loadCollapsed(s store.Store) map[int]bool {
m := make(map[int]bool) m := make(map[int]bool)
raw, err := s.GetPreference(context.Background(), "collapsed_groups") raw, err := s.GetPreference("collapsed_groups")
if err != nil || raw == "" { if err != nil || raw == "" {
return m return m
} }
@@ -28,9 +25,7 @@ func loadCollapsed(s store.Store) map[int]bool {
return m return m
} }
// collapsedJSON snapshots the collapsed-group set for persistence. Marshaling func saveCollapsed(s store.Store, collapsed map[int]bool) {
// happens on the UI goroutine so the write Cmd never reads the live map.
func collapsedJSON(collapsed map[int]bool) string {
var ids []int var ids []int
for id, v := range collapsed { for id, v := range collapsed {
if v { if v {
@@ -38,15 +33,7 @@ func collapsedJSON(collapsed map[int]bool) string {
} }
} }
data, _ := json.Marshal(ids) data, _ := json.Marshal(ids)
return string(data) _ = s.SetPreference("collapsed_groups", 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 sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site { func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site {
@@ -93,39 +80,41 @@ func filterSites(sites []models.Site, needle string) []models.Site {
return filtered return filtered
} }
// refreshLive updates everything sourced from in-memory engine copies — the func (m *Model) refreshData() {
// 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() allSites := m.engine.GetAllSites()
ordered := sortSitesForDisplay(allSites, m.collapsed) ordered := sortSitesForDisplay(allSites, m.collapsed)
if m.filterText != "" { if m.filterText != "" {
ordered = filterSites(ordered, m.filterText) ordered = filterSites(ordered, m.filterText)
} }
m.sites = ordered m.sites = ordered
m.refreshLogContent()
if m.currentTab == 0 && m.selectedID != 0 { if alerts, err := m.store.GetAllAlerts(); err == nil {
for i, s := range m.sites { m.alerts = alerts
if s.ID == m.selectedID { }
m.cursor = i if m.isAdmin {
break if users, err := m.store.GetAllUsers(); err == nil {
m.users = users
} }
} }
if nodes, err := m.store.GetAllNodes(); err == nil {
m.nodes = nodes
} }
m.clampCursor() if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
} m.maintenanceWindows = windows
}
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
func (m *Model) syncSelectedID() { listLen := len(m.sites)
if m.currentTab == 0 && m.cursor < len(m.sites) { switch m.currentTab {
m.selectedID = m.sites[m.cursor].ID case 1:
listLen = len(m.alerts)
case 3:
listLen = len(m.nodes)
case 4:
listLen = len(m.maintenanceWindows)
case 5:
listLen = len(m.users)
} }
}
// 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 { if listLen > 0 && m.cursor >= listLen {
m.cursor = listLen - 1 m.cursor = listLen - 1
} }
@@ -133,71 +122,3 @@ func (m *Model) clampCursor() {
m.tableOffset = m.cursor 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
isAdmin := m.isAdmin
return func() tea.Msg {
ctx := context.Background()
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
return func() tea.Msg {
return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)}
}
}
// 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
return func() tea.Msg {
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 100)}
}
}
// 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
since := time.Now().Add(-slaPeriods[periodIdx].duration)
return func() tea.Msg {
return slaDataMsg{
siteID: siteID,
periodIdx: periodIdx,
changes: eng.GetStateChangesSince(siteID, since),
}
}
}
+13 -13
View File
@@ -8,9 +8,9 @@ import (
func TestSortSitesForDisplay_GroupsFirst(t *testing.T) { func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 3, Name: "ungrouped", Type: "http"}, SiteState: models.SiteState{Status: "UP"}}, {ID: 3, Name: "ungrouped", Type: "http", Status: "UP"},
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}}, {ID: 1, Name: "group-a", Type: "group", Status: "UP"},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, {ID: 2, Name: "child", Type: "http", Status: "UP", ParentID: 1},
} }
result := sortSitesForDisplay(sites, nil) result := sortSitesForDisplay(sites, nil)
if len(result) != 3 { if len(result) != 3 {
@@ -29,9 +29,9 @@ func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) { func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}}, {ID: 1, Name: "group-a", Type: "group", Status: "UP"},
{SiteConfig: models.SiteConfig{ID: 2, Name: "child-1", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, {ID: 2, Name: "child-1", Type: "http", Status: "UP", ParentID: 1},
{SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}}, {ID: 3, Name: "child-2", Type: "http", Status: "UP", ParentID: 1},
} }
collapsed := map[int]bool{1: true} collapsed := map[int]bool{1: true}
result := sortSitesForDisplay(sites, collapsed) result := sortSitesForDisplay(sites, collapsed)
@@ -45,9 +45,9 @@ func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
func TestSortSitesForDisplay_StatusOrdering(t *testing.T) { func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{ID: 1, Name: "up-site", Type: "http"}, SiteState: models.SiteState{Status: "UP"}}, {ID: 1, Name: "up-site", Type: "http", Status: "UP"},
{SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}}, {ID: 2, Name: "down-site", Type: "http", Status: "DOWN"},
{SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}}, {ID: 3, Name: "late-site", Type: "http", Status: "LATE"},
} }
result := sortSitesForDisplay(sites, nil) result := sortSitesForDisplay(sites, nil)
if result[0].Status != "DOWN" { if result[0].Status != "DOWN" {
@@ -63,9 +63,9 @@ func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
func TestFilterSites(t *testing.T) { func TestFilterSites(t *testing.T) {
sites := []models.Site{ sites := []models.Site{
{SiteConfig: models.SiteConfig{Name: "Production API"}}, {Name: "Production API"},
{SiteConfig: models.SiteConfig{Name: "Staging API"}}, {Name: "Staging API"},
{SiteConfig: models.SiteConfig{Name: "Database"}}, {Name: "Database"},
} }
tests := []struct { tests := []struct {
@@ -87,7 +87,7 @@ func TestFilterSites(t *testing.T) {
} }
func TestFilterSites_EmptyNeedle(t *testing.T) { func TestFilterSites_EmptyNeedle(t *testing.T) {
sites := []models.Site{{SiteConfig: models.SiteConfig{Name: "a"}}, {SiteConfig: models.SiteConfig{Name: "b"}}} sites := []models.Site{{Name: "a"}, {Name: "b"}}
got := filterSites(sites, "") got := filterSites(sites, "")
if len(got) != 2 { if len(got) != 2 {
t.Errorf("empty needle should return all, got %d", len(got)) t.Errorf("empty needle should return all, got %d", len(got))
+36 -82
View File
@@ -2,41 +2,12 @@ package tui
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "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 { func limitStr(text string, max int) string {
if max < 3 {
return text
}
runes := []rune(text) runes := []rune(text)
if len(runes) > max { if len(runes) > max {
return string(runes[:max-3]) + "..." return string(runes[:max-3]) + "..."
@@ -51,8 +22,6 @@ func siteOrder(s models.Site) int {
switch s.Status { switch s.Status {
case "DOWN", "SSL EXP": case "DOWN", "SSL EXP":
return 0 return 0
case "STALE":
return 1
case "LATE": case "LATE":
return 1 return 1
case "PENDING": case "PENDING":
@@ -84,10 +53,10 @@ func typeIcon(siteType string, collapsed bool) string {
} }
} }
func (m Model) fmtLatency(d time.Duration) string { func fmtLatency(d time.Duration) string {
ms := d.Milliseconds() ms := d.Milliseconds()
if ms == 0 { if ms == 0 {
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
} }
var s string var s string
if ms < 1000 { if ms < 1000 {
@@ -96,17 +65,17 @@ func (m Model) fmtLatency(d time.Duration) string {
s = fmt.Sprintf("%.1fs", float64(ms)/1000) s = fmt.Sprintf("%.1fs", float64(ms)/1000)
} }
if ms < 200 { if ms < 200 {
return m.st.specialStyle.Render(s) return specialStyle.Render(s)
} }
if ms < 500 { if ms < 500 {
return m.st.warnStyle.Render(s) return warnStyle.Render(s)
} }
return m.st.dangerStyle.Render(s) return dangerStyle.Render(s)
} }
func (m Model) fmtUptime(statuses []bool) string { func fmtUptime(statuses []bool) string {
if len(statuses) == 0 { if len(statuses) == 0 {
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
} }
up := 0 up := 0
for _, s := range statuses { for _, s := range statuses {
@@ -117,84 +86,69 @@ func (m Model) fmtUptime(statuses []bool) string {
pct := float64(up) / float64(len(statuses)) * 100 pct := float64(up) / float64(len(statuses)) * 100
s := fmt.Sprintf("%.1f%%", pct) s := fmt.Sprintf("%.1f%%", pct)
if pct >= 99 { if pct >= 99 {
return m.st.specialStyle.Render(s) return specialStyle.Render(s)
} }
if pct >= 95 { if pct >= 95 {
return m.st.warnStyle.Render(s) return warnStyle.Render(s)
} }
return m.st.dangerStyle.Render(s) return dangerStyle.Render(s)
} }
func (m Model) fmtSSL(site models.Site) string { func fmtSSL(site models.Site) string {
if site.Type != "http" || !site.CheckSSL || !site.HasSSL { if site.Type != "http" || !site.CheckSSL || !site.HasSSL {
return m.st.subtleStyle.Render("-") return subtleStyle.Render("-")
} }
days := int(time.Until(site.CertExpiry).Hours() / 24) days := int(time.Until(site.CertExpiry).Hours() / 24)
s := fmt.Sprintf("%dd", days) s := fmt.Sprintf("%dd", days)
if days <= 0 { if days <= 0 {
return m.st.dangerStyle.Render("EXPIRED") return dangerStyle.Render("EXPIRED")
} }
if days <= site.ExpiryThreshold { if days <= site.ExpiryThreshold {
return m.st.warnStyle.Render(s) return warnStyle.Render(s)
} }
return m.st.specialStyle.Render(s) return specialStyle.Render(s)
} }
func (m Model) fmtRetries(site models.Site) string { func fmtRetries(site models.Site) string {
dispCount := site.FailureCount dispCount := site.FailureCount
if dispCount > site.MaxRetries { if dispCount > site.MaxRetries {
dispCount = site.MaxRetries dispCount = site.MaxRetries
} }
s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries)
if site.Status == models.StatusDown { if site.Status == "DOWN" {
return m.st.dangerStyle.Render(s) return dangerStyle.Render(s)
} }
if site.Status == models.StatusUp && site.FailureCount > 0 { if site.Status == "UP" && site.FailureCount > 0 {
return m.st.warnStyle.Render(s) return warnStyle.Render(s)
} }
return s return s
} }
func (m Model) fmtStatus(status models.Status, paused bool, inMaint bool) string { func fmtStatus(status string, paused bool, inMaint bool, errCategory ErrorCategory) string {
if paused { if paused {
return m.st.warnStyle.Render("PAUSED") return warnStyle.Render("PAUSED")
} }
if inMaint { if inMaint {
return m.st.maintStyle.Render("MAINT") return maintStyle.Render("MAINT")
} }
switch status { switch status {
case models.StatusDown: case "DOWN":
return m.st.dangerStyle.Render("▼ DOWN") label := "DOWN"
case models.StatusSSLExp: if errCategory != ErrCatUnknown {
return m.st.dangerStyle.Render("▼ SSL EXP") label = "DOWN:" + string(errCategory)
case models.StatusLate: }
return m.st.warnStyle.Render("◆ LATE") return dangerStyle.Render(label)
case models.StatusStale: case "SSL EXP":
return m.st.staleStyle.Render("◆ STALE") return dangerStyle.Render(status)
case models.StatusPending: case "LATE":
return m.st.subtleStyle.Render("○ PENDING") return warnStyle.Render(status)
case "PENDING":
return subtleStyle.Render(status)
default: default:
return m.st.specialStyle.Render("▲ " + string(status)) return specialStyle.Render(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 { func fmtDuration(d time.Duration) string {
if d < time.Minute { if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds())) return fmt.Sprintf("%ds", int(d.Seconds()))
+30 -24
View File
@@ -7,8 +7,9 @@ import (
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models" "gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
) )
// styledModel carries a default-theme styles instance for render-helper tests. func init() {
var styledModel = Model{st: newStyles(themeFlexokiDark)} applyTheme(themeFlexokiDark)
}
func TestLimitStr(t *testing.T) { func TestLimitStr(t *testing.T) {
tests := []struct { tests := []struct {
@@ -38,13 +39,13 @@ func TestSiteOrder(t *testing.T) {
site models.Site site models.Site
want int want int
}{ }{
{"down", models.Site{SiteState: models.SiteState{Status: "DOWN"}}, 0}, {"down", models.Site{Status: "DOWN"}, 0},
{"ssl exp", models.Site{SiteState: models.SiteState{Status: "SSL EXP"}}, 0}, {"ssl exp", models.Site{Status: "SSL EXP"}, 0},
{"late", models.Site{SiteState: models.SiteState{Status: "LATE"}}, 1}, {"late", models.Site{Status: "LATE"}, 1},
{"up", models.Site{SiteState: models.SiteState{Status: "UP"}}, 2}, {"up", models.Site{Status: "UP"}, 2},
{"pending", models.Site{SiteState: models.SiteState{Status: "PENDING"}}, 3}, {"pending", models.Site{Status: "PENDING"}, 3},
{"paused up", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "UP"}}, 3}, {"paused up", models.Site{Status: "UP", Paused: true}, 3},
{"paused down", models.Site{SiteConfig: models.SiteConfig{Paused: true}, SiteState: models.SiteState{Status: "DOWN"}}, 3}, {"paused down", models.Site{Status: "DOWN", Paused: true}, 3},
} }
for _, tt := range tests { for _, tt := range tests {
got := siteOrder(tt.site) got := siteOrder(tt.site)
@@ -54,27 +55,32 @@ func TestSiteOrder(t *testing.T) {
} }
} }
func TestFmtStatus(t *testing.T) { func TestFmtStatus_ErrorCategory(t *testing.T) {
tests := []struct { tests := []struct {
status models.Status status string
paused bool paused bool
inMaint bool inMaint bool
cat ErrorCategory
wantSub string wantSub string
}{ }{
{models.StatusDown, false, false, "▼ DOWN"}, {"DOWN", false, false, ErrCatDNS, "DOWN:DNS"},
{models.StatusUp, false, false, "▲ UP"}, {"DOWN", false, false, ErrCatTLS, "DOWN:TLS"},
{models.StatusSSLExp, false, false, "▼ SSL EXP"}, {"DOWN", false, false, ErrCatHTTP, "DOWN:HTTP"},
{models.StatusLate, false, false, "◆ LATE"}, {"DOWN", false, false, ErrCatTCP, "DOWN:TCP"},
{models.StatusStale, false, false, "◆ STALE"}, {"DOWN", false, false, ErrCatTimeout, "DOWN:TMO"},
{models.StatusPending, false, false, "○ PENDING"}, {"DOWN", false, false, ErrCatICMP, "DOWN:ICMP"},
{models.StatusDown, true, false, "◇ PAUSED"}, {"DOWN", false, false, ErrCatPrivate, "DOWN:PRIV"},
{models.StatusDown, false, true, "◼ MAINT"}, {"DOWN", false, false, ErrCatUnknown, "DOWN"},
{"UP", false, false, ErrCatUnknown, "UP"},
{"SSL EXP", false, false, ErrCatUnknown, "SSL EXP"},
{"DOWN", true, false, ErrCatDNS, "PAUSED"},
{"DOWN", false, true, ErrCatDNS, "MAINT"},
} }
for _, tt := range tests { for _, tt := range tests {
got := styledModel.fmtStatus(tt.status, tt.paused, tt.inMaint) got := fmtStatus(tt.status, tt.paused, tt.inMaint, tt.cat)
if !containsPlain(got, tt.wantSub) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtStatus(%q, paused=%v, maint=%v): %q missing %q", t.Errorf("fmtStatus(%q, paused=%v, maint=%v, %q): %q missing %q",
tt.status, tt.paused, tt.inMaint, got, tt.wantSub) tt.status, tt.paused, tt.inMaint, tt.cat, got, tt.wantSub)
} }
} }
} }
@@ -135,7 +141,7 @@ func TestFmtUptime(t *testing.T) {
{"all down", []bool{false, false}, "0.0%"}, {"all down", []bool{false, false}, "0.0%"},
} }
for _, tt := range tests { for _, tt := range tests {
got := styledModel.fmtUptime(tt.statuses) got := fmtUptime(tt.statuses)
if !containsPlain(got, tt.wantSub) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub) t.Errorf("fmtUptime(%s): %q missing %q", tt.name, got, tt.wantSub)
} }
@@ -153,7 +159,7 @@ func TestFmtLatency(t *testing.T) {
{1500 * time.Millisecond, "1.5s"}, {1500 * time.Millisecond, "1.5s"},
} }
for _, tt := range tests { for _, tt := range tests {
got := styledModel.fmtLatency(tt.d) got := fmtLatency(tt.d)
if !containsPlain(got, tt.wantSub) { if !containsPlain(got, tt.wantSub) {
t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub) t.Errorf("fmtLatency(%v): %q missing %q", tt.d, got, tt.wantSub)
} }
-64
View File
@@ -1,64 +0,0 @@
package tui
import (
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes,
// maintenance windows) is reloaded. Live sites + logs come from in-memory
// engine copies and refresh every tick; the DB tables change rarely, so a 5s
// floor keeps tab-bar counts fresh without a per-second query storm.
const tabRefreshTTL = 5 * time.Second
// tickMsg is the once-per-second heartbeat. A named type (vs a bare time.Time)
// keeps it from colliding with any other time-valued message.
type tickMsg time.Time
// tabDataMsg carries the result of an async load of the DB-backed tab tables.
// On err, the model keeps its previous data and logs — never wiping the view on
// a transient store error. seq orders in-flight loads: replies whose seq is
// older than the model's current tabSeq are dropped, so a slow load can never
// overwrite the result of a newer one.
type tabDataMsg struct {
seq int
alerts []models.AlertConfig
users []models.User
nodes []models.ProbeNode
maint []models.MaintenanceWindow
err error
}
// detailDataMsg carries the state-change history for the detail panel, loaded
// on entry and refreshed on the tab-data cadence so View never touches the
// database.
type detailDataMsg struct {
siteID int
changes []models.StateChange
}
// historyDataMsg carries the full state-change history for the history view.
// siteID guards against a slow reply landing after the user opened a
// different site's history.
type historyDataMsg struct {
siteID int
changes []models.StateChange
}
// slaDataMsg carries the state changes backing the SLA view for one
// site+period request. siteID and periodIdx guard stale replies the same way
// historyDataMsg does.
type slaDataMsg struct {
siteID int
periodIdx int
changes []models.StateChange
}
// writeDoneMsg reports a store mutation that ran off the UI goroutine. op
// names the action for the error log; the handler reloads tab data so the UI
// converges on what was actually written.
type writeDoneMsg struct {
op string
err error
}
+25 -82
View File
@@ -1,63 +1,15 @@
package tui package tui
import ( import (
"fmt"
"strings" "strings"
"time" "time"
"github.com/charmbracelet/lipgloss"
) )
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
func parseHex(hex string) (r, g, b uint8) { func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
if len(hex) == 7 && hex[0] == '#' {
_, _ = fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b)
}
return
}
func dimColor(hex string, brightness float64) lipgloss.Color {
r, g, b := parseHex(hex)
f := 0.3 + brightness*0.7
return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x",
uint8(float64(r)*f),
uint8(float64(g)*f),
uint8(float64(b)*f),
))
}
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
if bg != "" {
return s.Background(bg)
}
return s
}
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
var hex string
var t float64
switch {
case ms < 200:
hex = m.st.sparkSuccess
t = float64(ms) / 200
case ms < 500:
hex = m.st.sparkWarning
t = float64(ms-200) / 300
default:
hex = m.st.sparkDanger
t = float64(ms-500) / 1500
if t > 1 {
t = 1
}
}
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
return withBg(s, bg)
}
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
if len(latencies) == 0 { if len(latencies) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) return subtleStyle.Render(strings.Repeat("·", width))
} }
samples := latencies samples := latencies
@@ -78,12 +30,12 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
maxL = l maxL = l
} }
} }
spread := maxL - minL
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 { if remaining := width - len(samples); remaining > 0 {
sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
} }
spread := maxL - minL
for i, l := range samples { for i, l := range samples {
idx := 0 idx := 0
if spread > 0 { if spread > 0 {
@@ -95,17 +47,24 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
ch := string(sparkChars[idx]) ch := string(sparkChars[idx])
isDown := i < len(sampledStatuses) && !sampledStatuses[i] isDown := i < len(sampledStatuses) && !sampledStatuses[i]
if isDown { if isDown {
sb.WriteString(withBg(m.st.dangerStyle, bg).Render(ch)) sb.WriteString(dangerStyle.Render(ch))
} else { } else {
sb.WriteString(m.latencyStyle(l.Milliseconds(), bg).Render(ch)) ms := l.Milliseconds()
if ms < 200 {
sb.WriteString(specialStyle.Render(ch))
} else if ms < 500 {
sb.WriteString(warnStyle.Render(ch))
} else {
sb.WriteString(dangerStyle.Render(ch))
}
} }
} }
return sb.String() return sb.String()
} }
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { func heartbeatSparkline(statuses []bool, width int) string {
if len(statuses) == 0 { if len(statuses) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) return subtleStyle.Render(strings.Repeat("·", width))
} }
samples := statuses samples := statuses
@@ -115,35 +74,19 @@ func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color)
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 { if remaining := width - len(samples); remaining > 0 {
sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
} }
for _, up := range samples { for _, up := range samples {
if up { if up {
sb.WriteString(withBg(m.st.specialStyle, bg).Render("▁")) sb.WriteString(specialStyle.Render("▁"))
} else { } else {
sb.WriteString(withBg(m.st.dangerStyle, bg).Render("█")) sb.WriteString(dangerStyle.Render("█"))
} }
} }
return sb.String() return sb.String()
} }
func resolveSparklineIndex(x, sparkWidth, dataLen int) int { func (m Model) groupSparkline(groupID int, width int) string {
visible := dataLen
if visible > sparkWidth {
visible = sparkWidth
}
padding := sparkWidth - visible
if x < padding {
return -1
}
offset := 0
if dataLen > sparkWidth {
offset = dataLen - sparkWidth
}
return offset + (x - padding)
}
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
allSites := m.engine.GetAllSites() allSites := m.engine.GetAllSites()
var childStatuses [][]bool var childStatuses [][]bool
for _, s := range allSites { for _, s := range allSites {
@@ -156,7 +99,7 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string
} }
if len(childStatuses) == 0 { if len(childStatuses) == 0 {
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width)) return subtleStyle.Render(strings.Repeat("·", width))
} }
maxLen := 0 maxLen := 0
@@ -184,13 +127,13 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string
var sb strings.Builder var sb strings.Builder
if remaining := width - len(aggregated); remaining > 0 { if remaining := width - len(aggregated); remaining > 0 {
sb.WriteString(withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", remaining))) sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
} }
for _, up := range aggregated { for _, up := range aggregated {
if up { if up {
sb.WriteString(withBg(m.st.subtleStyle, bg).Render("·")) sb.WriteString(specialStyle.Render(""))
} else { } else {
sb.WriteString(withBg(m.st.dangerStyle, bg).Render("")) sb.WriteString(dangerStyle.Render(""))
} }
} }
return sb.String() return sb.String()
@@ -208,7 +151,7 @@ func (m Model) groupUptime(groupID int) string {
} }
} }
if len(allStatuses) == 0 { if len(allStatuses) == 0 {
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
} }
total, up := 0, 0 total, up := 0, 0
for _, statuses := range allStatuses { for _, statuses := range allStatuses {
@@ -219,7 +162,7 @@ func (m Model) groupUptime(groupID int) string {
} }
} }
} }
return m.fmtUptime(func() []bool { return fmtUptime(func() []bool {
out := make([]bool, total) out := make([]bool, total)
idx := 0 idx := 0
for _, statuses := range allStatuses { for _, statuses := range allStatuses {
+6 -117
View File
@@ -4,11 +4,10 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_Empty(t *testing.T) {
got := styledModel.latencySparkline(nil, nil, 10, "") got := latencySparkline(nil, nil, 10)
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty sparkline should be dots, got %q", got) t.Errorf("empty sparkline should be dots, got %q", got)
} }
@@ -17,13 +16,10 @@ func TestLatencySparkline_Empty(t *testing.T) {
func TestLatencySparkline_SingleValue(t *testing.T) { func TestLatencySparkline_SingleValue(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond} latencies := []time.Duration{100 * time.Millisecond}
statuses := []bool{true} statuses := []bool{true}
got := styledModel.latencySparkline(latencies, statuses, 5, "") got := latencySparkline(latencies, statuses, 5)
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") t.Error("sparkline should not be empty")
} }
if !strings.Contains(got, "····") {
t.Errorf("single value with width=5 should have 4 dot padding, got %q", got)
}
} }
func TestLatencySparkline_WidthTruncation(t *testing.T) { func TestLatencySparkline_WidthTruncation(t *testing.T) {
@@ -33,90 +29,14 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
latencies[i] = time.Duration(i*50) * time.Millisecond latencies[i] = time.Duration(i*50) * time.Millisecond
statuses[i] = true statuses[i] = true
} }
got := styledModel.latencySparkline(latencies, statuses, 5, "") got := latencySparkline(latencies, statuses, 5)
if len(got) == 0 { if len(got) == 0 {
t.Error("sparkline should not be empty") t.Error("sparkline should not be empty")
} }
if strings.Contains(got, "·") {
t.Errorf("20 samples in width=5 should have no padding, got %q", got)
}
}
func TestLatencySparkline_RelativeHeight(t *testing.T) {
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
statuses := []bool{true, true, true}
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
runes := []rune(out)
if len(runes) < 3 {
t.Fatalf("expected 3 runes, got %d", len(runes))
}
if runes[0] == runes[1] {
t.Errorf("min and max should have different bar heights, got %c %c %c", runes[0], runes[1], runes[2])
}
}
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00"
st.sparkWarning = "#ffff00"
st.sparkDanger = "#ff0000"
m := Model{st: st}
green := m.latencyStyle(50, "")
yellow := m.latencyStyle(300, "")
red := m.latencyStyle(800, "")
gfg := green.GetForeground()
yfg := yellow.GetForeground()
rfg := red.GetForeground()
if gfg == yfg || yfg == rfg || gfg == rfg {
t.Errorf("bands should produce distinct foreground colors: green=%v yellow=%v red=%v", gfg, yfg, rfg)
}
}
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
st := newStyles(themeFlexokiDark)
st.sparkSuccess = "#00ff00"
m := Model{st: st}
dim := m.latencyStyle(10, "")
bright := m.latencyStyle(190, "")
if dim.GetForeground() == bright.GetForeground() {
t.Error("10ms and 190ms should have different brightness within green band")
}
}
func TestLatencySparkline_OutputWidth(t *testing.T) {
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
statuses := []bool{true, true, true}
got := styledModel.latencySparkline(latencies, statuses, 5, "")
count := utf8.RuneCountInString(stripANSI(got))
if count != 5 {
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
}
}
func stripANSI(s string) string {
var out strings.Builder
i := 0
for i < len(s) {
if s[i] == '\x1b' {
for i < len(s) && s[i] != 'm' {
i++
}
i++
continue
}
out.WriteByte(s[i])
i++
}
return out.String()
} }
func TestHeartbeatSparkline_Empty(t *testing.T) { func TestHeartbeatSparkline_Empty(t *testing.T) {
got := styledModel.heartbeatSparkline(nil, 10, "") got := heartbeatSparkline(nil, 10)
if !strings.Contains(got, "··········") { if !strings.Contains(got, "··········") {
t.Errorf("empty heartbeat should be dots, got %q", got) t.Errorf("empty heartbeat should be dots, got %q", got)
} }
@@ -124,7 +44,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
func TestHeartbeatSparkline_Mixed(t *testing.T) { func TestHeartbeatSparkline_Mixed(t *testing.T) {
statuses := []bool{true, false, true, true, false} statuses := []bool{true, false, true, true, false}
got := styledModel.heartbeatSparkline(statuses, 5, "") got := heartbeatSparkline(statuses, 5)
if len(got) == 0 { if len(got) == 0 {
t.Error("heartbeat sparkline should not be empty") t.Error("heartbeat sparkline should not be empty")
} }
@@ -132,39 +52,8 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
statuses := []bool{true, true} statuses := []bool{true, true}
got := styledModel.heartbeatSparkline(statuses, 5, "") got := heartbeatSparkline(statuses, 5)
if !strings.Contains(got, "···") { if !strings.Contains(got, "···") {
t.Errorf("should have dot padding for width > data, got %q", got) t.Errorf("should have dot padding for width > data, got %q", got)
} }
} }
func TestResolveSparklineIndex(t *testing.T) {
tests := []struct {
name string
x int
sparkWidth int
dataLen int
want int
}{
{"exact fit first", 0, 5, 5, 0},
{"exact fit last", 4, 5, 5, 4},
{"padding returns -1", 0, 10, 5, -1},
{"padding boundary", 4, 10, 5, -1},
{"first data after padding", 5, 10, 5, 0},
{"last data after padding", 9, 10, 5, 4},
{"truncated first visible", 0, 5, 20, 15},
{"truncated last visible", 4, 5, 20, 19},
{"single data point", 9, 10, 1, 0},
{"single data point on padding", 0, 10, 1, -1},
{"zero data", 0, 10, 0, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveSparklineIndex(tt.x, tt.sparkWidth, tt.dataLen)
if got != tt.want {
t.Errorf("resolveSparklineIndex(%d, %d, %d) = %d, want %d",
tt.x, tt.sparkWidth, tt.dataLen, got, tt.want)
}
})
}
}
+63 -132
View File
@@ -1,13 +1,10 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
neturl "net/url"
"sort"
"strings" "strings"
"time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor" "gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
@@ -42,10 +39,6 @@ type alertFormData struct {
GotifyURL string GotifyURL string
GotifyToken string GotifyToken string
GotifyPriority string GotifyPriority string
// Opsgenie
OpsgenieAPIKey string
OpsgeniePriority string
OpsgenieEU bool
} }
func fmtAlertType(t string) string { func fmtAlertType(t string) string {
@@ -68,14 +61,15 @@ func fmtAlertType(t string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t) return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t)
case "gotify": case "gotify":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t) return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t)
case "opsgenie":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2684FF")).Render(t)
default: default:
return t return t
} }
} }
func (m Model) fmtAlertConfig(alert models.AlertConfig) string { func fmtAlertConfig(alert struct {
Type string
Settings map[string]string
}) string {
switch alert.Type { switch alert.Type {
case "email": case "email":
host := alert.Settings["host"] host := alert.Settings["host"]
@@ -86,91 +80,72 @@ func (m Model) fmtAlertConfig(alert models.AlertConfig) string {
if host != "" { if host != "" {
return limitStr(host, 34) return limitStr(host, 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "ntfy": case "ntfy":
topic := alert.Settings["topic"] topic := alert.Settings["topic"]
url := alert.Settings["url"] url := alert.Settings["url"]
if url != "" && topic != "" { if url != "" && topic != "" {
return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) return limitStr(fmt.Sprintf("%s/%s", url, topic), 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "telegram": case "telegram":
if id := alert.Settings["chat_id"]; id != "" { if id := alert.Settings["chat_id"]; id != "" {
return limitStr(fmt.Sprintf("chat:%s", id), 34) return limitStr(fmt.Sprintf("chat:%s", id), 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "pagerduty": case "pagerduty":
if key := alert.Settings["routing_key"]; key != "" { if key := alert.Settings["routing_key"]; key != "" {
return limitStr(maskSecret(key), 34) return limitStr(key, 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "pushover": case "pushover":
if user := alert.Settings["user"]; user != "" { if user := alert.Settings["user"]; user != "" {
return limitStr(fmt.Sprintf("user:%s", maskSecret(user)), 34) return limitStr(fmt.Sprintf("user:%s", user), 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "gotify": case "gotify":
// The gotify server URL identifies the target; the token is the
// secret and is never shown here.
if url := alert.Settings["url"]; url != "" { if url := alert.Settings["url"]; url != "" {
return limitStr(url, 34) return limitStr(url, 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
case "opsgenie":
key := alert.Settings["api_key"]
if key != "" {
masked := maskSecret(key)
if alert.Settings["eu"] == "true" {
return limitStr(fmt.Sprintf("EU %s", masked), 34)
}
return limitStr(masked, 34)
}
return m.st.subtleStyle.Render("—")
default: default:
// discord/slack/webhook: the URL path IS the credential — show only if val, ok := alert.Settings["url"]; ok {
// enough to identify the target. return limitStr(val, 34)
if val, ok := alert.Settings["url"]; ok && val != "" {
return limitStr(maskWebhookURL(val), 34)
} }
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
} }
} }
// maskSecret keeps just enough of a credential to identify it. func fmtAlertHealth(h monitor.AlertHealth) string {
func maskSecret(s string) string {
if len(s) > 8 {
return s[:4] + "…" + s[len(s)-4:]
}
return "●●●●●●●●"
}
// maskWebhookURL shows scheme and host only. For discord, slack, and generic
// webhooks the URL path carries the token, so the path is never rendered.
func maskWebhookURL(raw string) string {
u, err := neturl.Parse(raw)
if err != nil || u.Host == "" {
return "●●●●●●●●"
}
return u.Scheme + "://" + u.Host + "/…"
}
func (m Model) fmtAlertHealth(h monitor.AlertHealth) string {
if h.LastSendAt.IsZero() { if h.LastSendAt.IsZero() {
return m.st.subtleStyle.Render("●") return subtleStyle.Render("●")
} }
if h.LastSendOK { if h.LastSendOK {
return m.st.specialStyle.Render("●") return specialStyle.Render("●")
} }
return m.st.dangerStyle.Render("●") return dangerStyle.Render("●")
} }
func (m Model) fmtAlertLastSent(h monitor.AlertHealth) string { func fmtAlertLastSent(h monitor.AlertHealth) string {
return m.fmtTimeAgo(h.LastSendAt) if h.LastSendAt.IsZero() {
return subtleStyle.Render("never")
}
d := time.Since(h.LastSendAt)
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 (m Model) viewAlertsTab() string { func (m Model) viewAlertsTab() string {
if len(m.alerts) == 0 { if len(m.alerts) == 0 {
return m.emptyState("No alert channels configured.", "[n] Add your first alert") return "\n No alert channels configured. Press [n] to add one."
} }
var headers []string var headers []string
@@ -195,11 +170,14 @@ func (m Model) viewAlertsTab() string {
h := m.engine.GetAlertHealth(a.ID) h := m.engine.GetAlertHealth(a.ID)
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", i+1), fmt.Sprintf("%d", i+1),
m.fmtAlertHealth(h), fmtAlertHealth(h),
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
fmtAlertType(a.Type), fmtAlertType(a.Type),
limitStr(m.fmtAlertConfig(a), cfgW-2), limitStr(fmtAlertConfig(struct {
m.fmtAlertLastSent(h), Type string
Settings map[string]string
}{a.Type, a.Settings}), cfgW-2),
fmtAlertLastSent(h),
}) })
} }
return rows return rows
@@ -217,55 +195,39 @@ func (m Model) viewAlertDetailPanel() string {
var b strings.Builder var b strings.Builder
b.WriteString(m.st.subtleStyle.Render(" Alerts > ") + m.st.titleStyle.Render(a.Name) + "\n") b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) { row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
} }
row("Type", fmtAlertType(a.Type)) row("Type", fmtAlertType(a.Type))
if h.LastSendAt.IsZero() { if h.LastSendAt.IsZero() {
row("Health", m.st.subtleStyle.Render("never sent")) row("Health", subtleStyle.Render("never sent"))
} else if h.LastSendOK { } else if h.LastSendOK {
row("Health", m.st.specialStyle.Render("OK")) row("Health", specialStyle.Render("OK"))
} else { } else {
row("Health", m.st.dangerStyle.Render("FAILED")) row("Health", dangerStyle.Render("FAILED"))
} }
if !h.LastSendAt.IsZero() { if !h.LastSendAt.IsZero() {
row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+m.fmtAlertLastSent(h)+")") row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")")
} }
if h.SendCount > 0 { if h.SendCount > 0 {
row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)) row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount))
} }
if h.LastError != "" { if h.LastError != "" {
row("Last Error", m.st.dangerStyle.Render(limitStr(h.LastError, 60))) row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60)))
} }
b.WriteString(m.divider() + "\n") b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n")
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n") for k, v := range a.Settings {
// Render through the same allowlist the backup export uses — this panel
// ends up in screen shares and asciinema recordings. Keys are sorted so
// rows don't reshuffle every render.
redacted := models.RedactAlertSettings(a.Type, a.Settings)
keys := make([]string, 0, len(redacted))
for k := range redacted {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := redacted[k]
if v == "***REDACTED***" {
row(k, m.st.subtleStyle.Render("●●●●●●●●"))
continue
}
row(k, v) row(k, v)
} }
b.WriteString(m.divider() + "\n") b.WriteString("\n\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [t] Test")) b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
@@ -276,7 +238,6 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
NtfyPri: "3", NtfyPri: "3",
PagerDutySeverity: "critical", PagerDutySeverity: "critical",
GotifyPriority: "5", GotifyPriority: "5",
OpsgeniePriority: "P3",
} }
if m.editID > 0 { if m.editID > 0 {
@@ -314,10 +275,6 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
m.alertFormData.GotifyURL = alert.Settings["url"] m.alertFormData.GotifyURL = alert.Settings["url"]
m.alertFormData.GotifyToken = alert.Settings["token"] m.alertFormData.GotifyToken = alert.Settings["token"]
m.alertFormData.GotifyPriority = alert.Settings["priority"] m.alertFormData.GotifyPriority = alert.Settings["priority"]
case "opsgenie":
m.alertFormData.OpsgenieAPIKey = alert.Settings["api_key"]
m.alertFormData.OpsgeniePriority = alert.Settings["priority"]
m.alertFormData.OpsgenieEU = alert.Settings["eu"] == "true"
} }
break break
} }
@@ -346,7 +303,6 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
huh.NewOption("PagerDuty", "pagerduty"), huh.NewOption("PagerDuty", "pagerduty"),
huh.NewOption("Pushover", "pushover"), huh.NewOption("Pushover", "pushover"),
huh.NewOption("Gotify", "gotify"), huh.NewOption("Gotify", "gotify"),
huh.NewOption("Opsgenie", "opsgenie"),
).Value(&m.alertFormData.AlertType), ).Value(&m.alertFormData.AlertType),
).Title("Alert Config"), ).Title("Alert Config"),
huh.NewGroup( huh.NewGroup(
@@ -454,29 +410,12 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
).Title("Gotify Settings").WithHideFunc(func() bool { ).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify" return m.alertFormData.AlertType != "gotify"
}), }),
huh.NewGroup(
huh.NewInput().Title("API Key").
Placeholder("your-opsgenie-api-key").
Value(&m.alertFormData.OpsgenieAPIKey),
huh.NewSelect[string]().Title("Priority").
Options(
huh.NewOption("Critical (P1)", "P1"),
huh.NewOption("High (P2)", "P2"),
huh.NewOption("Moderate (P3)", "P3"),
huh.NewOption("Low (P4)", "P4"),
huh.NewOption("Informational (P5)", "P5"),
).Value(&m.alertFormData.OpsgeniePriority),
huh.NewConfirm().Title("EU Instance?").
Value(&m.alertFormData.OpsgenieEU),
).Title("Opsgenie Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "opsgenie"
}),
).WithTheme(m.theme.HuhTheme()) ).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitAlertForm() tea.Cmd { func (m *Model) submitAlertForm() {
d := m.alertFormData d := m.alertFormData
settings := make(map[string]string) settings := make(map[string]string)
@@ -507,26 +446,18 @@ func (m *Model) submitAlertForm() tea.Cmd {
settings["url"] = d.GotifyURL settings["url"] = d.GotifyURL
settings["token"] = d.GotifyToken settings["token"] = d.GotifyToken
settings["priority"] = d.GotifyPriority settings["priority"] = d.GotifyPriority
case "opsgenie":
settings["api_key"] = d.OpsgenieAPIKey
settings["priority"] = d.OpsgeniePriority
if d.OpsgenieEU {
settings["eu"] = "true"
}
default: default:
settings["url"] = d.WebhookURL settings["url"] = d.WebhookURL
} }
st := m.store if m.editID > 0 {
id := m.editID if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil {
name, aType := d.Name, d.AlertType m.engine.AddLog("Update alert failed: " + err.Error())
m.state = stateDashboard
if id > 0 {
return writeCmd("Update alert", func() error {
return st.UpdateAlert(context.Background(), id, name, aType, settings)
})
} }
return writeCmd("Add alert", func() error { } else {
return st.AddAlert(context.Background(), name, aType, settings) if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil {
}) m.engine.AddLog("Add alert failed: " + err.Error())
}
}
m.state = stateDashboard
} }
-62
View File
@@ -1,62 +0,0 @@
package tui
import (
"strings"
"testing"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
)
func TestAlertDetailPanel_MasksSecretsStableOrder(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.alerts = []models.AlertConfig{{
ID: 1, Name: "ops", Type: "email",
Settings: map[string]string{
"host": "smtp.example.com",
"port": "587",
"user": "oncall@example.com",
"pass": "hunter2-secret",
"to": "team@example.com",
},
}}
m.cursor = 0
out := m.viewAlertDetailPanel()
if strings.Contains(out, "hunter2-secret") {
t.Error("SMTP password rendered in alert detail panel")
}
if strings.Contains(out, "oncall@example.com") {
t.Error("SMTP user (not on the allowlist) rendered in alert detail panel")
}
if !strings.Contains(out, "smtp.example.com") {
t.Error("allowlisted setting (host) missing from panel")
}
// Map iteration must not reshuffle rows between renders.
for i := 0; i < 5; i++ {
if m.viewAlertDetailPanel() != out {
t.Fatal("panel output unstable across renders — settings keys not sorted")
}
}
}
func TestFmtAlertConfig_MasksSecrets(t *testing.T) {
m := newTestModel(&tuiMockStore{})
webhook := m.fmtAlertConfig(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}})
if strings.Contains(webhook, "SeCrEtToKeN") || strings.Contains(webhook, "123456") {
t.Errorf("webhook URL path (the credential) rendered in table: %q", webhook)
}
if !strings.Contains(webhook, "discord.com") {
t.Errorf("webhook host missing from table config: %q", webhook)
}
pd := m.fmtAlertConfig(models.AlertConfig{Type: "pagerduty", Settings: map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}})
if strings.Contains(pd, "R0123456789ABCDEFGHIJ") {
t.Errorf("pagerduty routing key rendered raw in table: %q", pd)
}
if !strings.Contains(pd, "R012") || !strings.Contains(pd, "GHIJ") {
t.Errorf("masked routing key should keep identifying ends: %q", pd)
}
}
+23 -30
View File
@@ -48,30 +48,30 @@ func isImportantLog(sev logSeverity) bool {
return sev == severityDown || sev == severityUp || sev == severitySystem return sev == severityDown || sev == severityUp || sev == severitySystem
} }
func (m Model) renderLogTag(sev logSeverity) string { func renderLogTag(sev logSeverity) string {
switch sev { switch sev {
case severityDown: case severityDown:
return m.st.dangerStyle.Render(" DOWN ") return dangerStyle.Render(" DOWN ")
case severityUp: case severityUp:
return m.st.specialStyle.Render(" UP ") return specialStyle.Render(" UP ")
case severityWarn: case severityWarn:
return m.st.warnStyle.Render(" WARN ") return warnStyle.Render(" WARN ")
case severitySystem: case severitySystem:
return m.st.titleStyle.Render(" SYS ") return titleStyle.Render(" SYS ")
default: default:
return m.st.subtleStyle.Render(" info ") return subtleStyle.Render(" info ")
} }
} }
func (m Model) renderLogLine(line string) string { func renderLogLine(line string) string {
sev := classifyLog(line) sev := classifyLog(line)
tag := m.renderLogTag(sev) tag := renderLogTag(sev)
ts := "" ts := ""
msg := line msg := line
if len(line) > 10 && line[0] == '[' { if len(line) > 10 && line[0] == '[' {
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
ts = m.st.subtleStyle.Render(line[1:idx]) ts = subtleStyle.Render(line[1:idx])
msg = strings.TrimSpace(line[idx+1:]) msg = strings.TrimSpace(line[idx+1:])
} }
} }
@@ -82,15 +82,18 @@ func (m Model) renderLogLine(line string) string {
return fmt.Sprintf(" %s %s", tag, msg) return fmt.Sprintf(" %s %s", tag, msg)
} }
// refreshLogContent rebuilds the log viewport from the full engine log list, func (m Model) viewLogsTab() string {
// filtering before windowing so the entry count and "(n hidden)" reflect all content := m.logViewport.View()
// logs, not just the visible viewport slice. if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
func (m *Model) refreshLogContent() { return "\n No log entries yet. Logs appear as monitors run checks."
}
lines := strings.Split(content, "\n")
var rendered []string var rendered []string
total := 0 total := 0
shown := 0 shown := 0
for _, line := range m.engine.GetLogs() { for _, line := range lines {
if strings.TrimSpace(line) == "" { if strings.TrimSpace(line) == "" {
continue continue
} }
@@ -100,17 +103,7 @@ func (m *Model) refreshLogContent() {
continue continue
} }
shown++ shown++
rendered = append(rendered, m.renderLogLine(line)) rendered = append(rendered, renderLogLine(line))
}
m.logTotal = total
m.logShown = shown
m.logViewport.SetContent(strings.Join(rendered, "\n"))
}
func (m Model) viewLogsTab() string {
if m.logTotal == 0 {
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
} }
filterLabel := "All" filterLabel := "All"
@@ -118,12 +111,12 @@ func (m Model) viewLogsTab() string {
filterLabel = "Important" filterLabel = "Important"
} }
header := m.st.subtleStyle.Render(fmt.Sprintf( header := subtleStyle.Render(fmt.Sprintf(
" %d entries Filter: %s", m.logShown, filterLabel)) " %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel))
if m.logFilterImportant && m.logShown < m.logTotal { if m.logFilterImportant && shown < total {
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown)) header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown))
} }
return "\n" + header + "\n\n" + m.logViewport.View() return "\n" + header + "\n\n" + strings.Join(rendered, "\n")
} }
+21 -20
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"strconv" "strconv"
"time" "time"
@@ -10,8 +9,11 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
) )
var maintStyle lipgloss.Style
type maintFormData struct { type maintFormData struct {
Title string Title string
Description string Description string
@@ -21,22 +23,22 @@ type maintFormData struct {
CustomHours string CustomHours string
} }
func (m Model) fmtMaintStatus(mw models.MaintenanceWindow) string { func fmtMaintStatus(mw models.MaintenanceWindow) string {
now := time.Now() now := time.Now()
if mw.StartTime.After(now) { if mw.StartTime.After(now) {
return m.st.warnStyle.Render("SCHEDULED") return warnStyle.Render("SCHEDULED")
} }
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) { if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
return m.st.subtleStyle.Render("ENDED") return subtleStyle.Render("ENDED")
} }
return m.st.specialStyle.Render("ACTIVE") return specialStyle.Render("ACTIVE")
} }
func (m Model) fmtMaintType(t string) string { func fmtMaintType(t string) string {
if t == "incident" { if t == "incident" {
return m.st.dangerStyle.Render("incident") return dangerStyle.Render("incident")
} }
return m.st.maintStyle.Render("maintenance") return maintStyle.Render("maintenance")
} }
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string { func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
@@ -51,9 +53,9 @@ func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
return fmt.Sprintf("#%d", monitorID) return fmt.Sprintf("#%d", monitorID)
} }
func (m Model) fmtMaintTime(t time.Time, colW int) string { func fmtMaintTime(t time.Time, colW int) string {
if t.IsZero() { if t.IsZero() {
return m.st.subtleStyle.Render("—") return subtleStyle.Render("—")
} }
now := time.Now() now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
@@ -91,7 +93,7 @@ func (m Model) isMonitorInMaintenance(monitorID int) bool {
func (m Model) viewMaintTab() string { func (m Model) viewMaintTab() string {
if len(m.maintenanceWindows) == 0 { if len(m.maintenanceWindows) == 0 {
return m.emptyState("No maintenance windows or incidents.", "[n] Create one") return "\n No maintenance windows or incidents. Press [n] to create one."
} }
var headers []string var headers []string
@@ -118,11 +120,11 @@ func (m Model) viewMaintTab() string {
rows = append(rows, []string{ rows = append(rows, []string{
strconv.Itoa(i + 1), strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)), m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)),
m.fmtMaintType(mw.Type), fmtMaintType(mw.Type),
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2), fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
m.fmtMaintStatus(mw), fmtMaintStatus(mw),
m.fmtMaintTime(mw.StartTime, timeW), fmtMaintTime(mw.StartTime, timeW),
m.fmtMaintTime(mw.EndTime, timeW), fmtMaintTime(mw.EndTime, timeW),
}) })
} }
return rows return rows
@@ -207,7 +209,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitMaintForm() tea.Cmd { func (m *Model) submitMaintForm() {
d := m.maintFormData d := m.maintFormData
monitorID, _ := strconv.Atoi(d.MonitorID) monitorID, _ := strconv.Atoi(d.MonitorID)
@@ -238,9 +240,8 @@ func (m *Model) submitMaintForm() tea.Cmd {
} }
} }
st := m.store if err := m.store.AddMaintenanceWindow(mw); err != nil {
m.engine.AddLog("Add maintenance window failed: " + err.Error())
}
m.state = stateDashboard m.state = stateDashboard
return writeCmd("Add maintenance window", func() error {
return st.AddMaintenanceWindow(context.Background(), mw)
})
} }
+23 -12
View File
@@ -1,12 +1,13 @@
package tui package tui
import ( import (
"fmt"
"time" "time"
) )
func (m Model) viewNodesTab() string { func (m Model) viewNodesTab() string {
if len(m.nodes) == 0 { if len(m.nodes) == 0 {
return m.emptyState("No probe nodes connected.", "") return "\n No probe nodes connected."
} }
var headers []string var headers []string
@@ -33,14 +34,14 @@ func (m Model) viewNodesTab() string {
} }
region := node.Region region := node.Region
if region == "" { if region == "" {
region = m.st.subtleStyle.Render("—") region = subtleStyle.Render("—")
} }
lastSeen := m.fmtNodeLastSeen(node.LastSeen) lastSeen := fmtNodeLastSeen(node.LastSeen)
version := node.Version version := node.Version
if version == "" { if version == "" {
version = m.st.subtleStyle.Render("—") version = subtleStyle.Render("—")
} }
status := m.fmtNodeStatus(node.LastSeen) status := fmtNodeStatus(node.LastSeen)
rows = append(rows, []string{name, region, lastSeen, version, status}) rows = append(rows, []string{name, region, lastSeen, version, status})
} }
return rows return rows
@@ -50,20 +51,30 @@ func (m Model) viewNodesTab() string {
) )
} }
func (m Model) fmtNodeStatus(lastSeen time.Time) string { func fmtNodeStatus(lastSeen time.Time) string {
if lastSeen.IsZero() { if lastSeen.IsZero() {
return m.st.subtleStyle.Render("UNKNOWN") return subtleStyle.Render("UNKNOWN")
} }
ago := time.Since(lastSeen) ago := time.Since(lastSeen)
if ago < 60*time.Second { if ago < 60*time.Second {
return m.st.specialStyle.Render("ONLINE") return specialStyle.Render("ONLINE")
} }
if ago < 5*time.Minute { if ago < 5*time.Minute {
return m.st.warnStyle.Render("STALE") return warnStyle.Render("STALE")
} }
return m.st.dangerStyle.Render("OFFLINE") return dangerStyle.Render("OFFLINE")
} }
func (m Model) fmtNodeLastSeen(t time.Time) string { func fmtNodeLastSeen(t time.Time) string {
return m.fmtTimeAgo(t) if t.IsZero() {
return subtleStyle.Render("never")
}
ago := time.Since(t)
if ago < time.Minute {
return fmt.Sprintf("%ds ago", int(ago.Seconds()))
}
if ago < time.Hour {
return fmt.Sprintf("%dm ago", int(ago.Minutes()))
}
return fmt.Sprintf("%dh ago", int(ago.Hours()))
} }
+69 -147
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"net/url" "net/url"
"strconv" "strconv"
@@ -12,6 +11,8 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var siteGroupStyle lipgloss.Style
type siteFormData struct { type siteFormData struct {
Name string Name string
SiteType string SiteType string
@@ -32,74 +33,27 @@ type siteFormData struct {
Regions string Regions string
} }
type colKey int
const (
colNum colKey = iota
colName
colType
colStatus
colLatency
colUptime
colHistory
colSSL
colRetries
)
type columnDef struct {
key colKey
wide string
narrow string
wideW int
narrowW int
minTerm int // minimum terminal width to show (0 = always)
}
var siteColumns = []columnDef{
{colNum, "#", "#", 4, 4, 0},
{colName, "NAME", "NAME", 0, 0, 0},
{colType, "TYPE", "TYPE", 10, 8, mediumBreakpoint},
{colStatus, "STATUS", "STATUS", 10, 10, 0},
{colLatency, "LATENCY", "LAT", 10, 7, 0},
{colUptime, "UPTIME", "UP%", 8, 8, mediumBreakpoint},
{colHistory, "HISTORY", "HISTORY", 0, 0, mediumBreakpoint},
{colSSL, "SSL", "SSL", 7, 5, wideBreakpoint},
{colRetries, "RETRIES", "RT", 9, 5, wideBreakpoint},
}
type tableLayout struct { type tableLayout struct {
nameW, sparkW int nameW, sparkW int
headers []string headers []string
colWidths []int colWidths []int
active []colKey
} }
func (m Model) computeLayout() tableLayout { func (m Model) computeLayout() tableLayout {
wide := m.isWide() wide := m.isWide()
var active []colKey var fixed int
var headers []string var headers []string
var widths []int var widths []int
var fixed int
for _, c := range siteColumns {
if c.minTerm > 0 && m.termWidth < c.minTerm {
continue
}
active = append(active, c.key)
if wide { if wide {
headers = append(headers, c.wide) headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"}
widths = append(widths, c.wideW) widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9}
if c.wideW > 0 { fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9
fixed += c.wideW
}
} else { } else {
headers = append(headers, c.narrow) headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"}
widths = append(widths, c.narrowW) widths = []int{4, 0, 8, 10, 7, 8, 0, 5, 5}
if c.narrowW > 0 { fixed = 4 + 8 + 10 + 7 + 8 + 5 + 5
fixed += c.narrowW
}
}
} }
numCols := len(headers) numCols := len(headers)
@@ -117,71 +71,46 @@ func (m Model) computeLayout() tableLayout {
} }
maxName += 4 maxName += 4
hasHistory := false nameW := avail / 2
for _, k := range active {
if k == colHistory {
hasHistory = true
break
}
}
var nameW, sparkW int
if hasHistory {
nameW = avail / 2
sparkW = avail - nameW
} else {
nameW = avail
sparkW = 0
}
if nameW > maxName { if nameW > maxName {
nameW = maxName nameW = maxName
} }
if nameW < 13 { if nameW < 13 {
nameW = 13 nameW = 13
} }
if nameW > 35 { if nameW > 40 {
nameW = 35 nameW = 40
}
if sparkW > 0 {
if sparkW < 15 {
sparkW = 15
}
if sparkW > 62 {
sparkW = 62
}
} }
for i, k := range active { sparkW := avail - nameW
if k == colName { if sparkW < 10 {
widths[i] = nameW sparkW = 10
}
if k == colHistory {
widths[i] = sparkW
}
} }
widths[1] = nameW
widths[6] = sparkW
return tableLayout{ return tableLayout{
nameW: nameW, nameW: nameW,
sparkW: sparkW, sparkW: sparkW,
headers: headers, headers: headers,
colWidths: widths, colWidths: widths,
active: active,
} }
} }
func pickCols(active []colKey, allCells map[colKey]string) []string {
row := make([]string, len(active))
for i, k := range active {
row[i] = allCells[k]
}
return row
}
func (m Model) viewSitesTab() string { func (m Model) viewSitesTab() string {
if len(m.sites) == 0 { if len(m.sites) == 0 {
return m.emptyState(m.st.titleStyle.Render("uptop")+"\n\nNo monitors configured yet.", "[n] Add your first monitor") welcome := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Accent).
Padding(1, 3).
Render(
titleStyle.Render("uptop") + "\n\n" +
"No monitors configured yet.\n\n" +
subtleStyle.Render("[n] Add your first monitor"),
)
return "\n" + welcome
} }
layout := m.computeLayout() layout := m.computeLayout()
@@ -190,9 +119,6 @@ func (m Model) viewSitesTab() string {
if sparkWidth < 8 { if sparkWidth < 8 {
sparkWidth = 8 sparkWidth = 8
} }
if sparkWidth > 60 {
sparkWidth = 60
}
var groupRows map[int]bool var groupRows map[int]bool
return m.renderTable( return m.renderTable(
@@ -203,29 +129,21 @@ func (m Model) viewSitesTab() string {
var rows [][]string var rows [][]string
for i := start; i < end; i++ { for i := start; i < end; i++ {
site := m.sites[i] site := m.sites[i]
rowIdx := i - start
var rowBg lipgloss.Color
if i == m.cursor {
rowBg = m.theme.SelectedBg
} else if rowIdx%2 == 1 {
rowBg = m.theme.ZebraBg
}
if site.Type == "group" { if site.Type == "group" {
groupRows[i-start] = true groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID]) icon := typeIcon("group", m.collapsed[site.ID])
cells := map[colKey]string{ rows = append(rows, []string{
colNum: strconv.Itoa(i + 1), strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)), m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
colType: "group", "group",
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), ErrCatUnknown),
colLatency: m.st.subtleStyle.Render("—"), subtleStyle.Render("—"),
colUptime: m.groupUptime(site.ID), m.groupUptime(site.ID),
colHistory: m.groupSparkline(site.ID, sparkWidth, rowBg), m.groupSparkline(site.ID, sparkWidth),
colSSL: m.st.subtleStyle.Render("-"), subtleStyle.Render("-"),
colRetries: m.st.subtleStyle.Render("—"), subtleStyle.Render("—"),
} })
rows = append(rows, pickCols(layout.active, cells))
continue continue
} }
@@ -240,7 +158,7 @@ func (m Model) viewSitesTab() string {
name = limitStr(name, nameW-2) name = limitStr(name, nameW-2)
} }
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
nameLen := len([]rune(name)) nameLen := len([]rune(name))
errSpace := nameW - nameLen - 3 errSpace := nameW - nameLen - 3
if errSpace > 10 { if errSpace > 10 {
@@ -250,37 +168,36 @@ func (m Model) viewSitesTab() string {
if tag != "" { if tag != "" {
errText = tag + " " + errText errText = tag + " " + errText
} }
name = name + " " + m.st.subtleStyle.Render(limitStr(errText, errSpace)) name = name + " " + subtleStyle.Render(limitStr(errText, errSpace))
} }
} }
hist, _ := m.engine.GetHistory(site.ID) hist, _ := m.engine.GetHistory(site.ID)
var spark string var spark string
if site.Type == "push" { if site.Type == "push" {
spark = m.heartbeatSparkline(hist.Statuses, sparkWidth, rowBg) spark = heartbeatSparkline(hist.Statuses, sparkWidth)
} else { } else {
spark = m.latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg) spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)
} }
cells := map[colKey]string{ rows = append(rows, []string{
colNum: strconv.Itoa(i + 1), strconv.Itoa(i + 1),
colName: m.zones.Mark(fmt.Sprintf("site-%d", i), name), m.zones.Mark(fmt.Sprintf("site-%d", i), name),
colType: typeIcon(site.Type, false) + " " + site.Type, typeIcon(site.Type, false) + " " + site.Type,
colStatus: m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), classifyError(site.LastError, site.Type, site.StatusCode)),
colLatency: m.fmtLatency(site.Latency), fmtLatency(site.Latency),
colUptime: m.fmtUptime(hist.Statuses), fmtUptime(hist.Statuses),
colHistory: spark, spark,
colSSL: m.fmtSSL(site), fmtSSL(site),
colRetries: m.fmtRetries(site), fmtRetries(site),
} })
rows = append(rows, pickCols(layout.active, cells))
} }
return rows return rows
}, },
layout.colWidths, layout.colWidths,
func(row, col int) *lipgloss.Style { func(row, col int) *lipgloss.Style {
if groupRows[row] { if groupRows[row] {
s := m.st.siteGroupStyle s := siteGroupStyle
return &s return &s
} }
return nil return nil
@@ -326,14 +243,15 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
} }
} }
// m.alerts is the tab-data cache (≤5s stale) — no store IO in Update.
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, a := range m.alerts { if alerts, err := m.store.GetAllAlerts(); err == nil {
for _, a := range alerts {
alertOpts = append(alertOpts, huh.NewOption( alertOpts = append(alertOpts, huh.NewOption(
fmt.Sprintf("%s (%s)", a.Name, a.Type), fmt.Sprintf("%s (%s)", a.Name, a.Type),
strconv.Itoa(a.ID), strconv.Itoa(a.ID),
)) ))
} }
}
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")} groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites { for _, s := range m.sites {
@@ -519,7 +437,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitSiteForm() tea.Cmd { func (m *Model) submitSiteForm() {
d := m.siteFormData d := m.siteFormData
interval, _ := strconv.Atoi(d.Interval) interval, _ := strconv.Atoi(d.Interval)
alertID, _ := strconv.Atoi(d.AlertID) alertID, _ := strconv.Atoi(d.AlertID)
@@ -535,7 +453,7 @@ func (m *Model) submitSiteForm() tea.Cmd {
threshold = 7 threshold = 7
} }
cfg := models.SiteConfig{ site := models.Site{
ID: m.editID, ID: m.editID,
Name: d.Name, Name: d.Name,
URL: d.URL, URL: d.URL,
@@ -556,11 +474,15 @@ func (m *Model) submitSiteForm() tea.Cmd {
Regions: d.Regions, Regions: d.Regions,
} }
st := m.store
m.state = stateDashboard
if m.editID > 0 { if m.editID > 0 {
m.engine.UpdateSiteConfig(cfg) if err := m.store.UpdateSite(site); err != nil {
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) }) m.engine.AddLog("Update site failed: " + err.Error())
} }
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) }) m.engine.UpdateSiteConfig(site)
} else {
if err := m.store.AddSite(site); err != nil {
m.engine.AddLog("Add site failed: " + err.Error())
}
}
m.state = stateDashboard
} }
+14 -17
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -14,9 +13,9 @@ type userFormData struct {
Role string Role string
} }
func (m Model) fmtRole(role string) string { func fmtRole(role string) string {
if role == "admin" { if role == "admin" {
return m.st.specialStyle.Render(role) return specialStyle.Render(role)
} }
return role return role
} }
@@ -30,7 +29,7 @@ func fmtKey(key string) string {
func (m Model) viewUsersTab() string { func (m Model) viewUsersTab() string {
if len(m.users) == 0 { if len(m.users) == 0 {
return m.emptyState("No users configured.", "[n] Add a user") return "\n No users configured. Press [n] to add one."
} }
var headers []string var headers []string
@@ -54,7 +53,7 @@ func (m Model) viewUsersTab() string {
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", i+1), fmt.Sprintf("%d", i+1),
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)), m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)),
m.fmtRole(u.Role), fmtRole(u.Role),
fmtKey(u.PublicKey), fmtKey(u.PublicKey),
}) })
} }
@@ -111,18 +110,16 @@ func (m *Model) initUserHuhForm() tea.Cmd {
return m.huhForm.Init() return m.huhForm.Init()
} }
func (m *Model) submitUserForm() tea.Cmd { func (m *Model) submitUserForm() {
d := m.userFormData d := m.userFormData
st := m.store if m.editID > 0 {
id := m.editID if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil {
username, key, role := d.Username, d.PublicKey, d.Role m.engine.AddLog("Update user failed: " + err.Error())
m.state = stateUsers
if id > 0 {
return writeCmd("Update user", func() error {
return st.UpdateUser(context.Background(), id, username, key, role)
})
} }
return writeCmd("Add user", func() error { } else {
return st.AddUser(context.Background(), username, key, role) if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil {
}) m.engine.AddLog("Add user failed: " + err.Error())
}
}
m.state = stateUsers
} }
+15 -13
View File
@@ -5,12 +5,17 @@ import (
"github.com/charmbracelet/lipgloss/table" "github.com/charmbracelet/lipgloss/table"
) )
var (
tableHeaderStyle lipgloss.Style
tableCellStyle lipgloss.Style
tableSelectedStyle lipgloss.Style
tableBorderStyle lipgloss.Style
tableZebraStyle lipgloss.Style
)
type StyleOverride func(row, col int) *lipgloss.Style type StyleOverride func(row, col int) *lipgloss.Style
const ( const wideBreakpoint = 120
wideBreakpoint = 120
mediumBreakpoint = 90
)
func (m Model) isWide() bool { func (m Model) isWide() bool {
return m.termWidth >= wideBreakpoint return m.termWidth >= wideBreakpoint
@@ -45,13 +50,13 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
t := table.New(). t := table.New().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderStyle(m.st.tableBorderStyle). BorderStyle(tableBorderStyle).
Width(tableWidth). Width(tableWidth).
Headers(headers...). Headers(headers...).
Rows(rows...). Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style { StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow { if row == table.HeaderRow {
h := m.st.tableHeaderStyle h := tableHeaderStyle
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
h = h.Width(colWidths[col]).MaxWidth(colWidths[col]) h = h.Width(colWidths[col]).MaxWidth(colWidths[col])
} }
@@ -61,11 +66,8 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
if styleOverride != nil { if styleOverride != nil {
if s := styleOverride(row, col); s != nil { if s := styleOverride(row, col); s != nil {
style := *s style := *s
if row%2 == 1 {
style = style.Background(m.st.tableZebraStyle.GetBackground())
}
if isSelected { if isSelected {
style = m.st.tableSelectedStyle.Foreground(s.GetForeground()) style = tableSelectedStyle.Foreground(s.GetForeground())
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
style = style.Width(colWidths[col]).MaxWidth(colWidths[col]) style = style.Width(colWidths[col]).MaxWidth(colWidths[col])
@@ -73,12 +75,12 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
return style return style
} }
} }
base := m.st.tableCellStyle base := tableCellStyle
if row%2 == 1 { if row%2 == 1 {
base = m.st.tableZebraStyle base = tableZebraStyle
} }
if isSelected { if isSelected {
base = m.st.tableSelectedStyle base = tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col]).MaxWidth(colWidths[col]) base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
-6
View File
@@ -22,7 +22,6 @@ type Theme struct {
// Semantic // Semantic
Success lipgloss.Color Success lipgloss.Color
Warning lipgloss.Color Warning lipgloss.Color
Stale lipgloss.Color
Danger lipgloss.Color Danger lipgloss.Color
Info lipgloss.Color Info lipgloss.Color
Accent lipgloss.Color Accent lipgloss.Color
@@ -55,7 +54,6 @@ var themeFlexokiDark = Theme{
Subtle: "#6F6E69", Subtle: "#6F6E69",
Success: "#879A39", Success: "#879A39",
Warning: "#D0A215", Warning: "#D0A215",
Stale: "#DA702C",
Danger: "#D14D41", Danger: "#D14D41",
Info: "#4385BE", Info: "#4385BE",
Accent: "#3AA99F", Accent: "#3AA99F",
@@ -76,7 +74,6 @@ var themeTokyoNight = Theme{
Subtle: "#565f89", Subtle: "#565f89",
Success: "#9ece6a", Success: "#9ece6a",
Warning: "#e0af68", Warning: "#e0af68",
Stale: "#ff9e64",
Danger: "#f7768e", Danger: "#f7768e",
Info: "#7aa2f7", Info: "#7aa2f7",
Accent: "#7dcfff", Accent: "#7dcfff",
@@ -97,7 +94,6 @@ var themeGruvbox = Theme{
Subtle: "#7c6f64", Subtle: "#7c6f64",
Success: "#b8bb26", Success: "#b8bb26",
Warning: "#fabd2f", Warning: "#fabd2f",
Stale: "#fe8019",
Danger: "#fb4934", Danger: "#fb4934",
Info: "#83a598", Info: "#83a598",
Accent: "#8ec07c", Accent: "#8ec07c",
@@ -118,7 +114,6 @@ var themeCatppuccinMocha = Theme{
Subtle: "#6c7086", Subtle: "#6c7086",
Success: "#a6e3a1", Success: "#a6e3a1",
Warning: "#f9e2af", Warning: "#f9e2af",
Stale: "#fab387",
Danger: "#f38ba8", Danger: "#f38ba8",
Info: "#89b4fa", Info: "#89b4fa",
Accent: "#94e2d5", Accent: "#94e2d5",
@@ -139,7 +134,6 @@ var themeNord = Theme{
Subtle: "#4c566a", Subtle: "#4c566a",
Success: "#a3be8c", Success: "#a3be8c",
Warning: "#ebcb8b", Warning: "#ebcb8b",
Stale: "#d08770",
Danger: "#bf616a", Danger: "#bf616a",
Info: "#81a1c1", Info: "#81a1c1",
Accent: "#88c0d0", Accent: "#88c0d0",
+22 -70
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"context"
"os" "os"
"time" "time"
@@ -17,57 +16,33 @@ import (
zone "github.com/lrstanley/bubblezone" zone "github.com/lrstanley/bubblezone"
) )
// styles holds every theme-derived lipgloss style. Each Model owns its own var (
// instance (built by newStyles), so concurrent SSH sessions can run different
// themes without racing on shared package state. Never mutate after creation.
type styles struct {
subtleStyle lipgloss.Style subtleStyle lipgloss.Style
specialStyle lipgloss.Style specialStyle lipgloss.Style
warnStyle lipgloss.Style warnStyle lipgloss.Style
staleStyle lipgloss.Style
dangerStyle lipgloss.Style dangerStyle lipgloss.Style
titleStyle lipgloss.Style titleStyle lipgloss.Style
activeTab lipgloss.Style activeTab lipgloss.Style
inactiveTab lipgloss.Style inactiveTab lipgloss.Style
)
sparkSuccess string func applyTheme(t Theme) {
sparkWarning string subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle)
sparkDanger string specialStyle = lipgloss.NewStyle().Foreground(t.Success)
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
tableHeaderStyle lipgloss.Style tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1)
tableCellStyle lipgloss.Style tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle lipgloss.Style tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg)
tableBorderStyle lipgloss.Style tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border)
tableZebraStyle lipgloss.Style tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg)
siteGroupStyle lipgloss.Style siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent)
maintStyle lipgloss.Style maintStyle = lipgloss.NewStyle().Foreground(t.Purple)
}
func newStyles(t Theme) *styles {
return &styles{
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle),
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
warnStyle: lipgloss.NewStyle().Foreground(t.Warning),
staleStyle: lipgloss.NewStyle().Foreground(t.Stale),
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger),
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted),
sparkSuccess: string(t.Success),
sparkWarning: string(t.Warning),
sparkDanger: string(t.Danger),
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border),
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
maintStyle: lipgloss.NewStyle().Foreground(t.Purple),
}
} }
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
@@ -80,8 +55,6 @@ const (
chromeFooter = 2 // footer: "\n" prefix + text line chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines) chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
detailSparkWidth = 40
) )
type sessionState int type sessionState int
@@ -105,7 +78,6 @@ type Model struct {
state sessionState state sessionState
currentTab int currentTab int
cursor int cursor int
selectedID int
tableOffset int tableOffset int
maxTableRows int maxTableRows int
termWidth int termWidth int
@@ -121,13 +93,10 @@ type Model struct {
logViewport viewport.Model logViewport viewport.Model
logFilterImportant bool logFilterImportant bool
logTotal int
logShown int
historyViewport viewport.Model historyViewport viewport.Model
historyChanges []models.StateChange historyChanges []models.StateChange
historySiteName string historySiteName string
historySiteID int
slaViewport viewport.Model slaViewport viewport.Model
slaReport monitor.SLAReport slaReport monitor.SLAReport
@@ -148,7 +117,6 @@ type Model struct {
engine *monitor.Engine engine *monitor.Engine
theme Theme theme Theme
themeIndex int themeIndex int
st *styles
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
@@ -161,32 +129,23 @@ type Model struct {
users []models.User users []models.User
nodes []models.ProbeNode nodes []models.ProbeNode
maintenanceWindows []models.MaintenanceWindow maintenanceWindows []models.MaintenanceWindow
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
tabSeq int // seq of the newest issued tab-data load
// detail-panel state-change history, loaded on enter so View does no DB IO
detailChanges []models.StateChange
detailChangesSiteID int
filterMode bool filterMode bool
filterText string filterText string
sparkTooltipIdx int // clicked sparkline data index, -1 = none
// demoMode renders a stable status dot instead of the animated pulse so // demoMode renders a stable status dot instead of the animated pulse so
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1. // screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
demoMode bool demoMode bool
version string
} }
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model { func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
vpLogs := viewport.New(100, 20) vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...") vpLogs.SetContent("Waiting for logs...")
z := zone.New() z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(s) collapsed := loadCollapsed(s)
themeName, _ := s.GetPreference(context.Background(), "theme") themeName, _ := s.GetPreference("theme")
theme := themeByName(themeName) theme := themeByName(themeName)
themeIdx := 0 themeIdx := 0
for i, t := range themes { for i, t := range themes {
@@ -196,6 +155,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
} }
} }
applyTheme(theme)
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -208,19 +169,10 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
collapsed: collapsed, collapsed: collapsed,
theme: theme, theme: theme,
themeIndex: themeIdx, themeIndex: themeIdx,
st: newStyles(theme),
demoMode: os.Getenv("UPTOP_DEMO") == "1", demoMode: os.Getenv("UPTOP_DEMO") == "1",
version: version,
sparkTooltipIdx: -1,
} }
} }
// tickCmd schedules the next one-second heartbeat.
func tickCmd() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
// Load tab data immediately so the dashboard isn't empty for the first second. return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
return tea.Batch(tea.ClearScreen, tickCmd(), m.loadTabDataCmd())
} }
+92 -239
View File
@@ -1,7 +1,6 @@
package tui package tui
import ( import (
"context"
"fmt" "fmt"
"time" "time"
@@ -16,35 +15,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
return m.handleResize(msg) return m.handleResize(msg)
case tickMsg: case time.Time:
return m.handleTick(time.Time(msg)) return m.handleTick(msg)
case tabDataMsg:
return m.handleTabData(msg)
case detailDataMsg:
// Drop replies for a site the user has already navigated away from,
// so a slow load can't clobber the panel currently on screen.
if m.state == stateDetail && m.cursor < len(m.sites) && m.sites[m.cursor].ID != msg.siteID {
return m, nil
}
m.detailChanges = msg.changes
m.detailChangesSiteID = msg.siteID
return m, nil
case historyDataMsg:
if msg.siteID != m.historySiteID {
return m, nil // stale reply for a previously opened history
}
m.historyChanges = msg.changes
m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
return m, nil
case slaDataMsg:
return m.handleSLAData(msg)
case writeDoneMsg:
if msg.err != nil {
m.engine.AddLog(msg.op + " failed: " + msg.err.Error())
}
m.refreshLive()
return m, m.loadTabDataCmd()
} }
if m.state == stateConfirmDelete { if m.state == stateConfirmDelete {
@@ -70,34 +42,34 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
switch keyMsg.String() { switch keyMsg.String() {
case "y", "Y": case "y", "Y":
// The store delete runs in a Cmd; the in-memory engine/model updates
// stay here so the row vanishes immediately. If the delete fails, the
// writeDoneMsg reload converges the UI back to the DB state (and the
// engine poll loop re-adds a site that is still in the DB).
st := m.store
id := m.deleteID
var cmd tea.Cmd
switch m.deleteTab { switch m.deleteTab {
case 0: case 0:
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) }) if err := m.store.DeleteSite(m.deleteID); err != nil {
m.engine.RemoveSite(id) m.engine.AddLog("Delete site failed: " + err.Error())
}
m.engine.RemoveSite(m.deleteID)
m.adjustCursor(len(m.sites) - 1) m.adjustCursor(len(m.sites) - 1)
case 1: case 1:
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) }) if err := m.store.DeleteAlert(m.deleteID); err != nil {
m.engine.AddLog("Delete alert failed: " + err.Error())
}
m.adjustCursor(len(m.alerts) - 1) m.adjustCursor(len(m.alerts) - 1)
case 4: case 4:
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) }) if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
}
m.adjustCursor(len(m.maintenanceWindows) - 1) m.adjustCursor(len(m.maintenanceWindows) - 1)
case 5: case 5:
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) }) if err := m.store.DeleteUser(m.deleteID); err != nil {
m.engine.AddLog("Delete user failed: " + err.Error())
}
m.adjustCursor(len(m.users) - 1) m.adjustCursor(len(m.users) - 1)
} }
m.refreshLive() m.refreshData()
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 { if m.deleteTab == 5 {
m.state = stateUsers m.state = stateUsers
} }
return m, cmd
case "n", "N", "esc": case "n", "N", "esc":
m.state = stateDashboard m.state = stateDashboard
if m.deleteTab == 5 { if m.deleteTab == 5 {
@@ -110,6 +82,10 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
m.termWidth = wsm.Width
m.termHeight = wsm.Height
}
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "ctrl+c" { if keyMsg.String() == "ctrl+c" {
return m, tea.Quit return m, tea.Quit
@@ -129,108 +105,42 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
m.huhForm = f m.huhForm = f
} }
if m.huhForm.State == huh.StateCompleted { if m.huhForm.State == huh.StateCompleted {
// The store write runs in the returned Cmd; its writeDoneMsg m.submitForm()
// triggers the tab-data reload once the row actually exists. m.refreshData()
cmd := m.submitForm()
m.refreshLive()
m.huhForm = nil m.huhForm = nil
return m, cmd return m, nil
} }
return m, formCmd return m, formCmd
} }
return m, nil return m, nil
} }
func (m *Model) recalcLayout() { func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.termWidth = msg.Width
m.termHeight = msg.Height
chrome := chromeBase chrome := chromeBase
if m.filterMode || m.filterText != "" { if m.filterMode || m.filterText != "" {
chrome++ chrome++
} }
m.maxTableRows = m.termHeight - chrome m.maxTableRows = msg.Height - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
} }
}
func (m *Model) handleResize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.termWidth = msg.Width
m.termHeight = msg.Height
m.recalcLayout()
m.logViewport.Width = msg.Width - chromePadH m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeFooter + 2) m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
m.historyViewport.Width = msg.Width - chromePadH m.historyViewport.Width = msg.Width - chromePadH
m.historyViewport.Height = msg.Height - 10 m.historyViewport.Height = msg.Height - 10
m.slaViewport.Width = msg.Width - chromePadH m.slaViewport.Width = msg.Width - chromePadH
m.slaViewport.Height = msg.Height - 16 m.slaViewport.Height = msg.Height - 16
if m.huhForm != nil { return m, tea.ClearScreen
formHeight := msg.Height - 7
if formHeight < 5 {
formHeight = 5
}
m.huhForm.WithHeight(formHeight)
}
return m, nil
} }
func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) { func (m *Model) handleTick(t time.Time) (tea.Model, tea.Cmd) {
m.refreshLive() m.refreshData()
m.tickCount++ m.tickCount++
target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5 target := sinApprox(float64(m.tickCount)*0.3)*0.5 + 0.5
m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target) m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target)
return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })
cmds := []tea.Cmd{tickCmd()}
if t.Sub(m.lastTabLoad) > tabRefreshTTL {
m.lastTabLoad = t
cmds = append(cmds, m.loadTabDataCmd())
if dc := m.detailRefreshCmd(); dc != nil {
cmds = append(cmds, dc)
}
}
return m, tea.Batch(cmds...)
}
// detailRefreshCmd reloads the open detail panel's state-change list on the
// tab-data cadence, so a flap that happens while the panel is on screen shows
// up without leaving and re-entering. Nil when no detail panel is open.
func (m *Model) detailRefreshCmd() tea.Cmd {
if m.state != stateDetail || m.cursor >= len(m.sites) {
return nil
}
return m.loadDetailCmd(m.sites[m.cursor].ID)
}
// handleTabData folds an async tab-data load into the model. Replies older
// than the newest issued load are dropped so out-of-order completions can't
// overwrite fresher data. On error the previous data is kept and the failure
// logged, so a transient store error never blanks the view.
func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
if msg.seq != m.tabSeq {
return m, nil
}
if msg.err != nil {
m.engine.AddLog("Tab data refresh failed: " + msg.err.Error())
return m, nil
}
m.alerts = msg.alerts
if m.isAdmin {
m.users = msg.users
}
m.nodes = msg.nodes
m.maintenanceWindows = msg.maint
m.clampCursor()
return m, nil
}
// testAlertCmd sends a test notification off the UI goroutine; the outcome
// surfaces through the engine log (picked up by the next refreshLive).
func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
eng := m.engine
return func() tea.Msg {
if err := eng.TestAlert(id); err != nil {
eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err))
}
return nil
}
} }
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
@@ -252,12 +162,6 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if m.state == stateDetail {
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
return m.handleSparklineClick(msg)
}
return m, nil
}
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers { if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
return m, nil return m, nil
} }
@@ -293,7 +197,6 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
} }
m.syncSelectedID()
return m, nil return m, nil
} }
@@ -331,26 +234,24 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.filterText = "" m.filterText = ""
m.cursor = 0 m.cursor = 0
m.tableOffset = 0 m.tableOffset = 0
m.recalcLayout() m.refreshData()
m.refreshLive()
case "enter": case "enter":
m.filterMode = false m.filterMode = false
m.recalcLayout()
case "backspace": case "backspace":
if len(m.filterText) > 0 { if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1] m.filterText = m.filterText[:len(m.filterText)-1]
m.cursor = 0 m.cursor = 0
m.tableOffset = 0 m.tableOffset = 0
m.refreshLive() m.refreshData()
} }
case "ctrl+c": case "ctrl+c":
return m, tea.Quit return m, tea.Quit
default: default:
if len(msg.Runes) == 1 { if len(msg.String()) == 1 {
m.filterText += string(msg.Runes) m.filterText += msg.String()
m.cursor = 0 m.cursor = 0
m.tableOffset = 0 m.tableOffset = 0
m.refreshLive() m.refreshData()
} }
} }
return m, nil return m, nil
@@ -358,15 +259,7 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "esc": case "i", "esc":
if m.sparkTooltipIdx >= 0 {
m.sparkTooltipIdx = -1
return m, nil
}
m.sparkTooltipIdx = -1
m.state = stateDashboard
case "i":
m.sparkTooltipIdx = -1
m.state = stateDashboard m.state = stateDashboard
case "e": case "e":
return m.handleEditItem() return m.handleEditItem()
@@ -374,48 +267,25 @@ func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.cursor < len(m.sites) { if m.cursor < len(m.sites) {
site := m.sites[m.cursor] site := m.sites[m.cursor]
m.historySiteName = site.Name m.historySiteName = site.Name
m.historySiteID = site.ID m.historyChanges = m.engine.GetStateChanges(site.ID, 100)
m.historyChanges = nil
m.historyViewport = viewport.New( m.historyViewport = viewport.New(
m.termWidth-chromePadH, m.termWidth-chromePadH,
m.termHeight-10, m.termHeight-10,
) )
m.historyViewport.SetContent("\n Loading state history...") m.historyViewport.SetContent(m.buildHistoryContent())
m.historyViewport.GotoTop()
m.state = stateHistory m.state = stateHistory
return m, m.loadHistoryCmd(site.ID)
} }
case "s": case "s":
if m.cursor < len(m.sites) { if m.cursor < len(m.sites) {
return m, m.openSLAView(m.sites[m.cursor]) m.openSLAView(m.sites[m.cursor])
} }
case "q": case "q":
m.state = stateDashboard return m, tea.Quit
} }
return m, nil return m, nil
} }
func (m *Model) handleSparklineClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.cursor >= len(m.sites) {
return m, nil
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
if zi := m.zones.Get("spark-latency"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Latencies))
return m, nil
}
if zi := m.zones.Get("spark-heartbeat"); zi != nil && !zi.IsZero() && zi.InBounds(msg) {
x, _ := zi.Pos(msg)
m.sparkTooltipIdx = resolveSparklineIndex(x, detailSparkWidth, len(hist.Statuses))
return m, nil
}
m.sparkTooltipIdx = -1
return m, nil
}
func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q", "esc": case "q", "esc":
@@ -424,7 +294,7 @@ func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
idx := int(msg.String()[0]-'0') - 1 idx := int(msg.String()[0]-'0') - 1
if idx >= 0 && idx < len(slaPeriods) { if idx >= 0 && idx < len(slaPeriods) {
m.slaPeriodIdx = idx m.slaPeriodIdx = idx
return m, m.loadSLACmd(m.slaSiteID, idx) m.recomputeSLA()
} }
case "up", "k": case "up", "k":
m.slaViewport.ScrollUp(1) m.slaViewport.ScrollUp(1)
@@ -440,39 +310,26 @@ func (m *Model) handleSLAKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) openSLAView(site models.Site) tea.Cmd { func (m *Model) openSLAView(site models.Site) {
m.slaSiteName = site.Name m.slaSiteName = site.Name
m.slaSiteID = site.ID m.slaSiteID = site.ID
m.slaPeriodIdx = 2 // default 30d m.slaPeriodIdx = 2 // default 30d
m.slaViewport = viewport.New( m.recomputeSLA()
m.termWidth-chromePadH,
m.termHeight-16,
)
m.slaViewport.SetContent("\n Loading SLA report...")
m.state = stateSLA m.state = stateSLA
return m.loadSLACmd(site.ID, m.slaPeriodIdx)
} }
// handleSLAData folds an async SLA load into the model. The SLA math itself is func (m *Model) recomputeSLA() {
// pure CPU and cheap, so it runs here; only the state-change read happens in period := slaPeriods[m.slaPeriodIdx]
// the Cmd. Replies for a different site or period than currently selected are since := time.Now().Add(-period.duration)
// stale and dropped. changes := m.engine.GetStateChangesSince(m.slaSiteID, since)
func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
if msg.siteID != m.slaSiteID || msg.periodIdx != m.slaPeriodIdx {
return m, nil
}
period := slaPeriods[msg.periodIdx]
var currentStatus models.Status var currentStatus string
for _, s := range m.sites { if m.cursor < len(m.sites) {
if s.ID == msg.siteID { currentStatus = m.sites[m.cursor].Status
currentStatus = s.Status
break
}
} }
m.slaReport = monitor.ComputeSLA(msg.changes, currentStatus, period.duration) m.slaReport = monitor.ComputeSLA(changes, currentStatus, period.duration)
m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(msg.changes, currentStatus, period.days, time.Now()) m.slaDailyBreakdown = monitor.ComputeDailyBreakdown(changes, currentStatus, period.days)
m.slaViewport = viewport.New( m.slaViewport = viewport.New(
m.termWidth-chromePadH, m.termWidth-chromePadH,
@@ -480,7 +337,6 @@ func (m *Model) handleSLAData(msg slaDataMsg) (tea.Model, tea.Cmd) {
) )
m.slaViewport.SetContent(m.buildSLADailyContent()) m.slaViewport.SetContent(m.buildSLADailyContent())
m.slaViewport.GotoTop() m.slaViewport.GotoTop()
return m, nil
} }
func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -507,8 +363,10 @@ func (m *Model) handleHistoryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "q", "i", "esc": case "i", "esc":
m.state = stateDashboard m.state = stateDashboard
case "q":
return m, tea.Quit
} }
return m, nil return m, nil
} }
@@ -521,13 +379,11 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "/": case "/":
if m.currentTab == 0 { if m.currentTab == 0 {
m.filterMode = true m.filterMode = true
m.recalcLayout()
return m, nil return m, nil
} }
case "f": case "f":
if m.state == stateLogs { if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant m.logFilterImportant = !m.logFilterImportant
m.refreshLogContent()
return m, nil return m, nil
} }
case "tab": case "tab":
@@ -545,7 +401,6 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
m.tableOffset = m.cursor m.tableOffset = m.cursor
} }
m.syncSelectedID()
} }
case "down", "j": case "down", "j":
if m.state == stateLogs { if m.state == stateLogs {
@@ -557,7 +412,6 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.cursor >= m.tableOffset+m.maxTableRows { if m.cursor >= m.tableOffset+m.maxTableRows {
m.tableOffset++ m.tableOffset++
} }
m.syncSelectedID()
} }
} }
case "n": case "n":
@@ -567,33 +421,31 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "t": case "t":
if m.currentTab == 1 && len(m.alerts) > 0 { if m.currentTab == 1 && len(m.alerts) > 0 {
a := m.alerts[m.cursor] a := m.alerts[m.cursor]
return m, m.testAlertCmd(a.ID, a.Name) go func() {
if err := m.engine.TestAlert(a.ID); err != nil {
m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err))
}
}()
return m, nil
} }
case " ": case " ":
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
gid := m.sites[m.cursor].ID gid := m.sites[m.cursor].ID
m.collapsed[gid] = !m.collapsed[gid] m.collapsed[gid] = !m.collapsed[gid]
payload := collapsedJSON(m.collapsed) saveCollapsed(m.store, m.collapsed)
st := m.store m.refreshData()
m.refreshLive()
return m, writeCmd("Save collapsed groups", func() error {
return st.SetPreference(context.Background(), "collapsed_groups", payload)
})
} }
case "p": case "p":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
id := m.sites[m.cursor].ID site := m.sites[m.cursor]
paused := m.engine.ToggleSitePause(id) m.engine.ToggleSitePause(site.ID)
st := m.store site.Paused = !site.Paused
m.refreshLive() _ = m.store.UpdateSitePaused(site.ID, site.Paused)
return m, writeCmd("Update pause state", func() error { m.refreshData()
return st.UpdateSitePaused(context.Background(), id, paused)
})
} }
case "i": case "i":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
m.state = stateDetail m.state = stateDetail
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
} else if m.currentTab == 1 && len(m.alerts) > 0 { } else if m.currentTab == 1 && len(m.alerts) > 0 {
m.state = stateAlertDetail m.state = stateAlertDetail
} }
@@ -603,24 +455,18 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
now := time.Now() now := time.Now()
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
if isActive { if isActive {
st := m.store if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
id := mw.ID m.engine.AddLog("End maintenance failed: " + err.Error())
m.refreshLive() }
return m, writeCmd("End maintenance", func() error { m.refreshData()
return st.EndMaintenanceWindow(context.Background(), id)
})
} }
} }
case "T": case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes) m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex] m.theme = themes[m.themeIndex]
m.st = newStyles(m.theme) applyTheme(m.theme)
st := m.store _ = m.store.SetPreference("theme", m.theme.Name)
name := m.theme.Name case "d", "backspace":
return m, writeCmd("Save theme", func() error {
return st.SetPreference(context.Background(), "theme", name)
})
case "d":
return m.handleDeleteItem() return m.handleDeleteItem()
} }
return m, nil return m, nil
@@ -755,30 +601,37 @@ func (m *Model) switchTab(idx int) {
} }
} }
func (m *Model) adjustCursor(_ int) { func (m *Model) adjustCursor(newLen int) {
m.clampCursor() if m.cursor >= newLen && m.cursor > 0 {
m.cursor--
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
if m.tableOffset < 0 {
m.tableOffset = 0
}
}
} }
func (m *Model) submitForm() tea.Cmd { func (m *Model) submitForm() {
switch m.state { switch m.state {
case stateFormSite: case stateFormSite:
if m.siteFormData != nil { if m.siteFormData != nil {
return m.submitSiteForm() m.submitSiteForm()
} }
case stateFormAlert: case stateFormAlert:
if m.alertFormData != nil { if m.alertFormData != nil {
return m.submitAlertForm() m.submitAlertForm()
} }
case stateFormUser: case stateFormUser:
if m.userFormData != nil { if m.userFormData != nil {
return m.submitUserForm() m.submitUserForm()
} }
case stateFormMaint: case stateFormMaint:
if m.maintFormData != nil { if m.maintFormData != nil {
return m.submitMaintForm() m.submitMaintForm()
} }
} }
return nil
} }
func (m Model) currentListLen() int { func (m Model) currentListLen() int {
-336
View File
@@ -1,336 +0,0 @@
package tui
import (
"context"
"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"
tea "github.com/charmbracelet/bubbletea"
zone "github.com/lrstanley/bubblezone"
)
// --- minimal Store mock for TUI data-flow tests ---
type tuiMockStore struct {
storetest.BaseMock
alerts []models.AlertConfig
users []models.User
nodes []models.ProbeNode
maint []models.MaintenanceWindow
stateChanges []models.StateChange
stateChangeCalls int
deleteSiteCalls int
}
func (m *tuiMockStore) GetAllAlerts(_ context.Context) ([]models.AlertConfig, error) {
return m.alerts, nil
}
func (m *tuiMockStore) GetAllUsers(_ context.Context) ([]models.User, error) { return m.users, nil }
func (m *tuiMockStore) GetAllNodes(_ context.Context) ([]models.ProbeNode, error) {
return m.nodes, nil
}
func (m *tuiMockStore) GetStateChanges(_ context.Context, _ int, _ int) ([]models.StateChange, error) {
m.stateChangeCalls++
return m.stateChanges, nil
}
func (m *tuiMockStore) GetAllMaintenanceWindows(_ context.Context, _ int) ([]models.MaintenanceWindow, error) {
return m.maint, nil
}
func (m *tuiMockStore) DeleteSite(_ context.Context, _ int) error {
m.deleteSiteCalls++
return nil
}
func newTestModel(ms *tuiMockStore) Model {
return Model{
store: ms,
engine: monitor.NewEngine(ms),
isAdmin: true,
zones: zone.New(),
detailChangesSiteID: -1,
theme: themeFlexokiDark,
st: newStyles(themeFlexokiDark),
}
}
// --- Tests ---
func TestLoadTabDataCmd_ReturnsRows(t *testing.T) {
ms := &tuiMockStore{
alerts: []models.AlertConfig{{ID: 1, Name: "a"}},
nodes: []models.ProbeNode{{ID: "n1"}},
users: []models.User{{Username: "u"}},
maint: []models.MaintenanceWindow{{ID: 7}},
}
m := newTestModel(ms)
msg := m.loadTabDataCmd()()
td, ok := msg.(tabDataMsg)
if !ok {
t.Fatalf("expected tabDataMsg, got %T", msg)
}
if len(td.alerts) != 1 || len(td.nodes) != 1 || len(td.users) != 1 || len(td.maint) != 1 {
t.Errorf("unexpected counts: %+v", td)
}
if td.err != nil {
t.Errorf("unexpected err: %v", td.err)
}
}
func TestHandleTabData_PopulatesModel(t *testing.T) {
m := newTestModel(&tuiMockStore{})
msg := tabDataMsg{
alerts: []models.AlertConfig{{ID: 1}},
nodes: []models.ProbeNode{{ID: "n"}},
users: []models.User{{Username: "u"}},
maint: []models.MaintenanceWindow{{ID: 2}},
}
updated, _ := m.handleTabData(msg)
got := updated.(*Model)
if len(got.alerts) != 1 || len(got.nodes) != 1 || len(got.users) != 1 || len(got.maintenanceWindows) != 1 {
t.Errorf("model not populated: alerts=%d nodes=%d users=%d maint=%d",
len(got.alerts), len(got.nodes), len(got.users), len(got.maintenanceWindows))
}
}
func TestHandleTabData_ErrorKeepsPreviousData(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.alerts = []models.AlertConfig{{ID: 99}} // pre-existing data
updated, _ := m.handleTabData(tabDataMsg{err: errSentinel})
got := updated.(*Model)
if len(got.alerts) != 1 || got.alerts[0].ID != 99 {
t.Error("transient error wiped previous tab data")
}
}
var errSentinel = &stubErr{}
type stubErr struct{}
func (*stubErr) Error() string { return "boom" }
func TestDetailLoad_CachesAndViewDoesNoIO(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 1, Name: "site"}, SiteState: models.SiteState{Status: "DOWN"}}}
m.cursor = 0
m.state = stateDetail
m.termWidth = 120
m.termHeight = 40
// Entering detail dispatches the load Cmd.
cmd := m.loadDetailCmd(1)
if cmd == nil {
t.Fatal("loadDetailCmd returned nil")
}
msg := cmd()
dd, ok := msg.(detailDataMsg)
if !ok || dd.siteID != 1 || len(dd.changes) != 1 {
t.Fatalf("unexpected detailDataMsg: %+v", msg)
}
if ms.stateChangeCalls != 1 {
t.Fatalf("expected exactly 1 store hit from the load Cmd, got %d", ms.stateChangeCalls)
}
// Apply the msg through Update (caches into the model).
updated, _ := m.Update(dd)
m = updated.(Model)
if m.detailChangesSiteID != 1 || len(m.detailChanges) != 1 {
t.Fatalf("detail changes not cached: id=%d n=%d", m.detailChangesSiteID, len(m.detailChanges))
}
// Render the detail panel several times — it must read the cache, not the DB.
for i := 0; i < 3; i++ {
_ = m.viewDetailPanel()
}
if ms.stateChangeCalls != 1 {
t.Errorf("View performed DB IO: store hit %d times (want 1, from the Cmd only)", ms.stateChangeCalls)
}
}
func TestHandleTick_ThrottlesTabLoad(t *testing.T) {
m := newTestModel(&tuiMockStore{})
mp := &m
t0 := time.Unix(1_000_000, 0)
mp.handleTick(t0)
if !mp.lastTabLoad.Equal(t0) {
t.Fatalf("first tick should dispatch + stamp lastTabLoad=%v, got %v", t0, mp.lastTabLoad)
}
// Within the TTL → no new dispatch, stamp unchanged.
mp.handleTick(t0.Add(time.Second))
if !mp.lastTabLoad.Equal(t0) {
t.Errorf("tick within TTL should not re-dispatch; lastTabLoad=%v", mp.lastTabLoad)
}
// Past the TTL → dispatch again.
t2 := t0.Add(tabRefreshTTL + time.Second)
mp.handleTick(t2)
if !mp.lastTabLoad.Equal(t2) {
t.Errorf("tick past TTL should re-dispatch; lastTabLoad=%v want %v", mp.lastTabLoad, t2)
}
}
// keyMsg builds a plain-rune key message ("h", "s", ...).
func keyMsg(s string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
func TestHandleTabData_DropsStaleSeq(t *testing.T) {
m := newTestModel(&tuiMockStore{})
mp := &m
_ = mp.loadTabDataCmd() // seq 1 (superseded)
_ = mp.loadTabDataCmd() // seq 2 (newest)
updated, _ := mp.handleTabData(tabDataMsg{seq: 1, alerts: []models.AlertConfig{{ID: 1}}})
if got := updated.(*Model); len(got.alerts) != 0 {
t.Error("stale tab-data reply was applied over a newer in-flight load")
}
updated, _ = mp.handleTabData(tabDataMsg{seq: 2, alerts: []models.AlertConfig{{ID: 2}}})
if got := updated.(*Model); len(got.alerts) != 1 || got.alerts[0].ID != 2 {
t.Error("fresh tab-data reply was not applied")
}
}
func TestHistoryKey_LoadsOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 7, Name: "site"}}}
m.state = stateDetail
m.termWidth, m.termHeight = 120, 40
updated, cmd := (&m).handleDetailKey(keyMsg("h"))
if ms.stateChangeCalls != 0 {
t.Fatal("history keypress hit the store synchronously in Update")
}
got := updated.(*Model)
if got.state != stateHistory || got.historySiteID != 7 {
t.Fatalf("history view not opened: state=%v siteID=%d", got.state, got.historySiteID)
}
if cmd == nil {
t.Fatal("expected a history load Cmd")
}
msg := cmd()
hd, ok := msg.(historyDataMsg)
if !ok || hd.siteID != 7 || len(hd.changes) != 1 {
t.Fatalf("unexpected historyDataMsg: %+v", msg)
}
folded, _ := got.Update(hd)
m2 := folded.(Model)
if len(m2.historyChanges) != 1 {
t.Fatal("history reply not folded into the model")
}
// A reply for a previously opened site must not clobber the current one.
m2.historySiteID = 9
stale, _ := m2.Update(historyDataMsg{siteID: 7, changes: nil})
if m3 := stale.(Model); len(m3.historyChanges) != 1 {
t.Error("stale history reply overwrote the current view")
}
}
func TestSLAData_DropsStaleReply(t *testing.T) {
m := newTestModel(&tuiMockStore{})
m.termWidth, m.termHeight = 120, 40
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 3}, SiteState: models.SiteState{Status: "UP"}}}
if cmd := (&m).openSLAView(m.sites[0]); cmd == nil {
t.Fatal("openSLAView should return a load Cmd")
}
// Reply for a different period than currently selected → dropped.
// (slaDataMsg routes through a pointer-receiver handler, so Update
// returns *Model on this path.)
updated, _ := m.Update(slaDataMsg{siteID: 3, periodIdx: 0})
if mm := updated.(*Model); mm.slaDailyBreakdown != nil {
t.Error("stale SLA reply (old period) was applied")
}
// Matching reply → report computed.
updated, _ = updated.(*Model).Update(slaDataMsg{siteID: 3, periodIdx: m.slaPeriodIdx})
if mm := updated.(*Model); mm.slaDailyBreakdown == nil {
t.Error("matching SLA reply was not applied")
}
}
func TestConfirmDelete_WritesOffUIGoroutine(t *testing.T) {
ms := &tuiMockStore{}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 4, Name: "s"}}}
m.state = stateConfirmDelete
m.deleteTab = 0
m.deleteID = 4
updated, cmd := (&m).handleConfirmDelete(keyMsg("y"))
if ms.deleteSiteCalls != 0 {
t.Fatal("delete hit the store synchronously in Update")
}
if cmd == nil {
t.Fatal("expected a write Cmd")
}
if got := updated.(*Model); got.state != stateDashboard {
t.Fatalf("expected return to dashboard, got state %v", got.state)
}
wd, ok := cmd().(writeDoneMsg)
if !ok || wd.err != nil {
t.Fatalf("unexpected write result: %+v", wd)
}
if ms.deleteSiteCalls != 1 {
t.Fatalf("expected exactly 1 store delete from the Cmd, got %d", ms.deleteSiteCalls)
}
}
func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
m := newTestModel(&tuiMockStore{})
updated, cmd := m.Update(writeDoneMsg{op: "Delete site", err: errSentinel})
if cmd == nil {
t.Error("writeDoneMsg did not trigger a tab-data reload")
}
mm := updated.(Model)
found := false
for _, line := range mm.engine.GetLogs() {
if strings.Contains(line, "Delete site failed: boom") {
found = true
}
}
if !found {
t.Error("write error was not logged")
}
}
func TestDetailRefreshCmd_OnlyWhileDetailOpen(t *testing.T) {
ms := &tuiMockStore{stateChanges: []models.StateChange{{FromStatus: "UP", ToStatus: "DOWN"}}}
m := newTestModel(ms)
m.sites = []models.Site{{SiteConfig: models.SiteConfig{ID: 5, Name: "site"}}}
m.state = stateDashboard
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued outside the detail view")
}
m.state = stateDetail
cmd := (&m).detailRefreshCmd()
if cmd == nil {
t.Fatal("open detail panel should refresh on the tab-data cadence")
}
dd, ok := cmd().(detailDataMsg)
if !ok || dd.siteID != 5 || len(dd.changes) != 1 {
t.Fatalf("unexpected detail refresh reply: %+v", dd)
}
m.cursor = 7 // cursor out of range → no refresh, no panic
if (&m).detailRefreshCmd() != nil {
t.Error("refresh Cmd issued for an out-of-range cursor")
}
}
+57 -61
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -17,7 +16,7 @@ func sinApprox(x float64) float64 {
func (m Model) pulseIndicator() string { func (m Model) pulseIndicator() string {
hasDown := false hasDown := false
for _, s := range m.sites { for _, s := range m.sites {
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == models.StatusDown || s.Status == models.StatusSSLExp) { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
hasDown = true hasDown = true
break break
} }
@@ -55,8 +54,8 @@ func (m Model) View() string {
case 5: case 5:
kind = "user" kind = "user"
} }
msg := m.st.dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
hint := m.st.subtleStyle.Render("[y] Confirm [n] Cancel") hint := subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Danger). BorderForeground(m.theme.Danger).
@@ -85,13 +84,18 @@ func (m Model) View() string {
case stateFormMaint: case stateFormMaint:
title = "New Maintenance Window" title = "New Maintenance Window"
} }
header := m.st.titleStyle.Render(title) formHeight := m.termHeight - 7
footer := m.st.subtleStyle.Render("\n[Esc] Cancel") if formHeight < 5 {
formHeight = 5
}
m.huhForm.WithHeight(formHeight)
header := titleStyle.Render(title)
footer := subtleStyle.Render("\n[Esc] Cancel")
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
} }
return "" return ""
case stateDetail: case stateDetail:
return m.zones.Scan(m.viewDetailPanel()) return m.viewDetailPanel()
case stateHistory: case stateHistory:
return m.viewHistoryPanel() return m.viewHistoryPanel()
case stateSLA: case stateSLA:
@@ -123,9 +127,9 @@ func (m Model) computeStats() dashboardStats {
continue continue
} }
switch site.Status { switch site.Status {
case models.StatusDown, models.StatusSSLExp: case "DOWN", "SSL EXP":
s.downCount++ s.downCount++
case models.StatusLate: case "LATE":
s.lateCount++ s.lateCount++
} }
} }
@@ -167,61 +171,54 @@ func (m Model) viewDashboard() string {
} }
} }
content = strings.TrimSpace(content)
footer := m.renderFooter(stats) footer := m.renderFooter(stats)
outerPad := lipgloss.NewStyle().Padding(1, 2) s := lipgloss.NewStyle().Padding(1, 2)
_, frameV := outerPad.GetFrameSize() if m.termHeight > 0 {
availHeight := m.termHeight - frameV s = s.MaxHeight(m.termHeight)
if availHeight < 5 {
availHeight = 5
} }
return s.Render(header + "\n" + content + "\n" + footer)
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
if contentHeight < 1 {
contentHeight = 1
}
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
}
type tabEntry struct {
name string
count int
warn int
} }
func (m Model) renderTabBar(stats dashboardStats) string { func (m Model) renderTabBar(stats dashboardStats) string {
tabs := []tabEntry{ var sitesLabel string
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount}, if stats.downCount > 0 {
{"Alerts", len(m.alerts), 0}, sitesLabel = fmt.Sprintf("Sites (%d↓)", stats.downCount)
{"Logs", 0, 0}, } else if stats.lateCount > 0 {
{"Nodes", len(m.nodes), stats.offlineNodes}, sitesLabel = fmt.Sprintf("Sites (%d⚠)", stats.lateCount)
{"Maint", len(m.maintenanceWindows), stats.activeMaint}, } else if stats.totalMonitors > 0 {
sitesLabel = fmt.Sprintf("Sites (%d)", stats.totalMonitors)
} else {
sitesLabel = "Sites"
} }
var nodesLabel string
if stats.offlineNodes > 0 {
nodesLabel = fmt.Sprintf("Nodes (%d!)", stats.offlineNodes)
} else if len(m.nodes) > 0 {
nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes))
} else {
nodesLabel = "Nodes"
}
var maintLabel string
if stats.activeMaint > 0 {
maintLabel = fmt.Sprintf("Maint (%d)", stats.activeMaint)
} else {
maintLabel = "Maint"
}
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
if m.isAdmin { if m.isAdmin {
tabs = append(tabs, tabEntry{"Users", len(m.users), 0}) tabs = append(tabs, "Users")
} }
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
var renderedTabs []string var renderedTabs []string
for i, t := range tabs { for i, t := range tabs {
label := t.name
if t.count > 0 {
badge := countStyle.Render(fmt.Sprintf(" %d", t.count))
if t.warn > 0 {
badge = m.st.dangerStyle.Render(fmt.Sprintf(" %d", t.warn))
}
label += badge
}
var rendered string var rendered string
if i == m.currentTab { if i == m.currentTab {
rendered = m.st.activeTab.Render(label) rendered = activeTab.Render(t)
} else { } else {
rendered = m.st.inactiveTab.Render(label) rendered = inactiveTab.Render(t)
} }
renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered))
} }
@@ -231,21 +228,21 @@ func (m Model) renderTabBar(stats dashboardStats) string {
func (m Model) renderFooter(stats dashboardStats) string { func (m Model) renderFooter(stats dashboardStats) string {
if m.filterMode { if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│") cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
return "\n" + m.st.titleStyle.Render("/") + " " + m.filterText + cursor + " " + m.st.subtleStyle.Render("[Enter]Apply [Esc]Clear") return "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
} }
upCount := stats.totalMonitors - stats.downCount - stats.lateCount upCount := stats.totalMonitors - stats.downCount - stats.lateCount
var upStr string var upStr string
if stats.downCount > 0 { if stats.downCount > 0 {
upStr = m.st.dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
} else if stats.lateCount > 0 { } else if stats.lateCount > 0 {
upStr = m.st.warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
} else { } else {
upStr = m.st.specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors)) upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, stats.totalMonitors))
} }
statusParts := []string{upStr} statusParts := []string{upStr}
if stats.lateCount > 0 { if stats.lateCount > 0 {
statusParts = append(statusParts, m.st.warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount))) statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", stats.lateCount)))
} }
if len(m.nodes) > 0 { if len(m.nodes) > 0 {
online := 0 online := 0
@@ -260,16 +257,16 @@ func (m Model) renderFooter(stats dashboardStats) string {
} }
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel)) statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
} }
statusLine := strings.Join(statusParts, m.st.subtleStyle.Render(" · ")) statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
var keys string var keys string
switch m.currentTab { switch m.currentTab {
case 0: case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit" keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
case 1: case 1:
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
case 2: case 2:
keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit" keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit"
case 4: case 4:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5: case 5:
@@ -278,10 +275,9 @@ func (m Model) renderFooter(stats dashboardStats) string {
keys = "[T]Theme [Tab]Switch [q]Quit" keys = "[T]Theme [Tab]Switch [q]Quit"
} }
ver := m.st.subtleStyle.Render("v" + m.version) footer := "\n" + statusLine + " " + subtleStyle.Render(keys)
footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
if m.filterText != "" && m.currentTab == 0 { if m.filterText != "" && m.currentTab == 0 {
footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
} }
return footer return footer
} }
+48 -109
View File
@@ -2,13 +2,10 @@ package tui
import ( import (
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -25,41 +22,41 @@ func (m Model) viewDetailPanel() string {
if site.ParentID > 0 { if site.ParentID > 0 {
for _, s := range m.sites { for _, s := range m.sites {
if s.ID == site.ParentID { if s.ID == site.ParentID {
breadcrumb = m.st.subtleStyle.Render(" Sites > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name) breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
break break
} }
} }
} }
if breadcrumb == "" { if breadcrumb == "" {
breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name) breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
} }
b.WriteString(breadcrumb + "\n") b.WriteString(breadcrumb + "\n\n")
b.WriteString(m.divider() + "\n")
row := func(label, value string) { row := func(label, value string) {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value) fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
} }
section := func(label string) { section := func(label string) {
b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n") b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n")
} }
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) errCat := classifyError(site.LastError, site.Type, site.StatusCode)
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID), errCat))
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" { if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
errWidth := m.termWidth - chromePadH - 19 errWidth := m.termWidth - chromePadH - 19
if errWidth < 30 { if errWidth < 30 {
errWidth = 30 errWidth = 30
} }
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError) wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError)
row("Error", m.st.dangerStyle.Render(wrapped)) row("Error", dangerStyle.Render(wrapped))
} }
if site.Type == "http" && site.StatusCode > 0 { if site.Type == "http" && site.StatusCode > 0 {
row("HTTP Code", strconv.Itoa(site.StatusCode)) row("HTTP Code", strconv.Itoa(site.StatusCode))
} }
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" { if (site.Status == "DOWN" || site.Status == "SSL EXP") && site.LastError != "" {
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https")) chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
if len(chain) > 0 { if len(chain) > 0 {
b.WriteString("\n") b.WriteString("\n")
@@ -67,19 +64,19 @@ func (m Model) viewDetailPanel() string {
var icon string var icon string
switch step.Status { switch step.Status {
case stepPassed: case stepPassed:
icon = m.st.specialStyle.Render("✓") icon = specialStyle.Render("✓")
case stepFailed: case stepFailed:
icon = m.st.dangerStyle.Render("✗") icon = dangerStyle.Render("✗")
case stepSkipped: case stepSkipped:
icon = m.st.subtleStyle.Render("·") icon = subtleStyle.Render("·")
} }
line := fmt.Sprintf(" %s %-16s", icon, step.Name) line := fmt.Sprintf(" %s %-16s", icon, step.Name)
if step.Detail != "" { if step.Detail != "" {
switch step.Status { switch step.Status {
case stepFailed: case stepFailed:
line += " " + m.st.dangerStyle.Render(step.Detail) line += " " + dangerStyle.Render(step.Detail)
case stepSkipped: case stepSkipped:
line += " " + m.st.subtleStyle.Render(step.Detail) line += " " + subtleStyle.Render(step.Detail)
} }
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
@@ -100,7 +97,7 @@ func (m Model) viewDetailPanel() string {
if m.isMonitorInMaintenance(site.ID) { if m.isMonitorInMaintenance(site.ID) {
for _, mw := range m.maintenanceWindows { for _, mw := range m.maintenanceWindows {
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) { if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
row("Maintenance", m.st.maintStyle.Render(mw.Title)) row("Maintenance", maintStyle.Render(mw.Title))
break break
} }
} }
@@ -108,10 +105,6 @@ func (m Model) viewDetailPanel() string {
section("ENDPOINT") section("ENDPOINT")
row("Type", site.Type) row("Type", site.Type)
if site.Type == "push" && site.Token != "" {
row("Token", site.Token)
row("Push", "curl -X POST -H 'Authorization: Bearer "+site.Token+"' <host>/api/push")
}
if site.URL != "" { if site.URL != "" {
row("URL", site.URL) row("URL", site.URL)
} }
@@ -127,10 +120,10 @@ func (m Model) viewDetailPanel() string {
if site.Timeout > 0 { if site.Timeout > 0 {
row("Timeout", fmt.Sprintf("%ds", site.Timeout)) row("Timeout", fmt.Sprintf("%ds", site.Timeout))
} }
row("Latency", m.fmtLatency(site.Latency)) row("Latency", fmtLatency(site.Latency))
row("Uptime", m.fmtUptime(hist.Statuses)) row("Uptime", fmtUptime(hist.Statuses))
if !site.LastCheck.IsZero() { if !site.LastCheck.IsZero() {
row("Last Check", m.fmtTimeAgo(site.LastCheck)) row("Last Check", site.LastCheck.Format("15:04:05"))
} }
if site.Type == "http" { if site.Type == "http" {
@@ -143,16 +136,16 @@ func (m Model) viewDetailPanel() string {
codes = "200-299" codes = "200-299"
} }
row("Codes", codes) row("Codes", codes)
row("SSL", m.fmtSSL(site)) row("SSL", fmtSSL(site))
if site.IgnoreTLS { if site.IgnoreTLS {
row("TLS Verify", m.st.dangerStyle.Render("disabled")) row("TLS Verify", dangerStyle.Render("disabled"))
} }
} }
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" { if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
section("CONFIG") section("CONFIG")
if site.MaxRetries > 0 { if site.MaxRetries > 0 {
row("Retries", m.fmtRetries(site)) row("Retries", fmtRetries(site))
} }
if site.Regions != "" { if site.Regions != "" {
row("Regions", site.Regions) row("Regions", site.Regions)
@@ -164,58 +157,49 @@ func (m Model) viewDetailPanel() string {
probeResults := m.engine.GetProbeResults(site.ID) probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 { if len(probeResults) > 0 {
nodeIDs := make([]string, 0, len(probeResults)) b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n")
for id := range probeResults { for nodeID, result := range probeResults {
nodeIDs = append(nodeIDs, id) status := specialStyle.Render("UP")
}
sort.Strings(nodeIDs)
b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n")
for _, nodeID := range nodeIDs {
result := probeResults[nodeID]
status := m.st.specialStyle.Render("UP")
if !result.IsUp { if !result.IsUp {
status = m.st.dangerStyle.Render("DN") status = dangerStyle.Render("DN")
} }
latency := time.Duration(result.LatencyNs).Milliseconds() latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second) ago := time.Since(result.CheckedAt).Truncate(time.Second)
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago) line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
if !result.IsUp && result.ErrorReason != "" { if !result.IsUp && result.ErrorReason != "" {
line += " " + m.st.dangerStyle.Render(result.ErrorReason) line += " " + dangerStyle.Render(result.ErrorReason)
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
} }
// Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO. stateChanges := m.engine.GetStateChanges(site.ID, 5)
var stateChanges []models.StateChange
if m.detailChangesSiteID == site.ID {
stateChanges = m.detailChanges
}
if len(stateChanges) > 0 { if len(stateChanges) > 0 {
b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n") b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
for i, sc := range stateChanges { for i, sc := range stateChanges {
ago := fmtDuration(time.Since(sc.ChangedAt)) ago := fmtDuration(time.Since(sc.ChangedAt))
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := subtleStyle.Render(sc.FromStatus) + " → "
if sc.ToStatus == string(models.StatusUp) { if sc.ToStatus == "UP" {
arrow += m.st.specialStyle.Render(sc.ToStatus) arrow += specialStyle.Render(sc.ToStatus)
} else { } else {
arrow += m.st.dangerStyle.Render(sc.ToStatus) arrow += dangerStyle.Render(sc.ToStatus)
} }
line := fmt.Sprintf(" %s %s", arrow, m.st.subtleStyle.Render(ago+" ago")) line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
if dur := computeOutageDuration(stateChanges, i); dur > 0 { if dur := computeOutageDuration(stateChanges, i); dur > 0 {
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur)) line += " " + warnStyle.Render("outage "+fmtDuration(dur))
} }
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) { if sc.ErrorReason != "" && sc.ToStatus != "UP" {
line += " " + m.st.dangerStyle.Render(sc.ErrorReason) line += " " + dangerStyle.Render(sc.ErrorReason)
} }
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
b.WriteString(" " + m.st.subtleStyle.Render("[h] History") + "\n") b.WriteString(" " + subtleStyle.Render("[h] History") + "\n")
} }
b.WriteString(m.divider() + "\n") b.WriteString("\n")
const sparkWidth = 40
if site.Type == "push" { if site.Type == "push" {
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, ""))) b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
if len(hist.Statuses) > 0 { if len(hist.Statuses) > 0 {
up := 0 up := 0
for _, s := range hist.Statuses { for _, s := range hist.Statuses {
@@ -224,11 +208,11 @@ func (m Model) viewDetailPanel() string {
} }
} }
fmt.Fprintf(&b, "\n %s %d/%d checks up", fmt.Fprintf(&b, "\n %s %d/%d checks up",
m.st.subtleStyle.Render("Heartbeats"), subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses)) up, len(hist.Statuses))
} }
} else { } else {
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, ""))) b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
var minL, maxL, total time.Duration var minL, maxL, total time.Duration
count := 0 count := 0
for i, l := range hist.Latencies { for i, l := range hist.Latencies {
@@ -248,59 +232,14 @@ func (m Model) viewDetailPanel() string {
if count > 0 { if count > 0 {
avg := total / time.Duration(count) avg := total / time.Duration(count)
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms", fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
m.st.subtleStyle.Render("Min"), minL.Milliseconds(), subtleStyle.Render("Min"), minL.Milliseconds(),
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(), subtleStyle.Render("Avg"), avg.Milliseconds(),
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()) subtleStyle.Render("Max"), maxL.Milliseconds())
} }
} }
if m.sparkTooltipIdx >= 0 { b.WriteString("\n\n")
b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth)) b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [h] History [s] SLA [q] Quit"))
}
b.WriteString("\n")
b.WriteString(m.divider() + "\n")
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string {
idx := m.sparkTooltipIdx
var dataLen int
if site.Type == "push" {
dataLen = len(hist.Statuses)
} else {
dataLen = len(hist.Latencies)
}
if idx < 0 || idx >= dataLen {
return ""
}
var parts []string
checksAgo := dataLen - 1 - idx
approxSecs := checksAgo * site.Interval
if approxSecs == 0 {
parts = append(parts, "latest")
} else {
parts = append(parts, "~"+fmtDuration(time.Duration(approxSecs)*time.Second)+" ago")
}
if site.Type != "push" && idx < len(hist.Latencies) {
parts = append(parts, m.fmtLatency(hist.Latencies[idx]))
}
if idx < len(hist.Statuses) {
if hist.Statuses[idx] {
parts = append(parts, m.st.specialStyle.Render("UP"))
} else {
parts = append(parts, m.st.dangerStyle.Render("DOWN"))
}
}
sep := m.st.subtleStyle.Render(" | ")
pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
return " " + strings.Join(parts, sep) + " " + pos
}
+32 -36
View File
@@ -17,14 +17,14 @@ type historyStats struct {
func computeOutageDuration(changes []models.StateChange, idx int) time.Duration { func computeOutageDuration(changes []models.StateChange, idx int) time.Duration {
sc := changes[idx] sc := changes[idx]
if sc.ToStatus != string(models.StatusUp) { if sc.ToStatus != "UP" {
return 0 return 0
} }
if idx+1 >= len(changes) { if idx+1 >= len(changes) {
return 0 return 0
} }
prev := changes[idx+1] prev := changes[idx+1]
if prev.ToStatus == string(models.StatusUp) { if prev.ToStatus == "UP" {
return 0 return 0
} }
dur := sc.ChangedAt.Sub(prev.ChangedAt) dur := sc.ChangedAt.Sub(prev.ChangedAt)
@@ -47,9 +47,7 @@ func computeHistoryStats(changes []models.StateChange) historyStats {
return s return s
} }
var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} func stateChangeSparkline(changes []models.StateChange, width int) string {
func (m Model) stateChangeSparkline(changes []models.StateChange, width int) string {
if len(changes) < 2 || width < 4 { if len(changes) < 2 || width < 4 {
return "" return ""
} }
@@ -93,14 +91,14 @@ func (m Model) stateChangeSparkline(changes []models.StateChange, width int) str
if idx > 7 { if idx > 7 {
idx = 7 idx = 7
} }
ch := string(stateChangeChars[idx]) ch := string(sparkChars[idx])
switch { switch {
case v >= 3: case v >= 3:
sb.WriteString(m.st.dangerStyle.Render(ch)) sb.WriteString(dangerStyle.Render(ch))
case v >= 2: case v >= 2:
sb.WriteString(m.st.warnStyle.Render(ch)) sb.WriteString(warnStyle.Render(ch))
default: default:
sb.WriteString(m.st.subtleStyle.Render(ch)) sb.WriteString(subtleStyle.Render(ch))
} }
} }
return sb.String() return sb.String()
@@ -120,26 +118,21 @@ func (m Model) buildHistoryContent() string {
for i, sc := range m.historyChanges { for i, sc := range m.historyChanges {
ts := sc.ChangedAt.Format("2006-01-02 15:04") ts := sc.ChangedAt.Format("2006-01-02 15:04")
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → " arrow := subtleStyle.Render(sc.FromStatus) + " → "
switch sc.ToStatus { if sc.ToStatus == "UP" {
case string(models.StatusUp): arrow += specialStyle.Render(sc.ToStatus)
arrow += m.st.specialStyle.Render(sc.ToStatus) } else {
case string(models.StatusLate): arrow += dangerStyle.Render(sc.ToStatus)
arrow += m.st.warnStyle.Render(sc.ToStatus)
case string(models.StatusStale):
arrow += m.st.staleStyle.Render(sc.ToStatus)
default:
arrow += m.st.dangerStyle.Render(sc.ToStatus)
} }
durStr := "" durStr := ""
if dur := computeOutageDuration(m.historyChanges, i); dur > 0 { if dur := computeOutageDuration(m.historyChanges, i); dur > 0 {
durStr = m.st.warnStyle.Render("outage " + fmtDuration(dur)) durStr = warnStyle.Render("outage " + fmtDuration(dur))
} }
reason := "" reason := ""
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) { if sc.ErrorReason != "" && sc.ToStatus != "UP" {
reason = m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth)) reason = dangerStyle.Render(limitStr(sc.ErrorReason, reasonWidth))
} }
fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason) fmt.Fprintf(&b, " %-18s %s %-12s %s\n", ts, arrow, durStr, reason)
@@ -151,32 +144,35 @@ func (m Model) buildHistoryContent() string {
func (m Model) viewHistoryPanel() string { func (m Model) viewHistoryPanel() string {
var b strings.Builder var b strings.Builder
header := " " + m.st.titleStyle.Render("STATE HISTORY: "+m.historySiteName) header := " " + titleStyle.Render("STATE HISTORY: "+m.historySiteName)
header += " " + m.st.subtleStyle.Render("[q] Back") header += " " + subtleStyle.Render("[q] Back")
b.WriteString(header + "\n") b.WriteString(header + "\n")
divWidth := m.dividerWidth() divWidth := m.termWidth - chromePadH - 4
b.WriteString(m.divider() + "\n") if divWidth < 40 {
divWidth = 40
}
b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
sparkline := m.stateChangeSparkline(m.historyChanges, divWidth) sparkline := stateChangeSparkline(m.historyChanges, divWidth)
if sparkline != "" { if sparkline != "" {
b.WriteString(" " + sparkline + "\n") b.WriteString(" " + sparkline + "\n")
b.WriteString(m.divider() + "\n") b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
} }
fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n", fmt.Fprintf(&b, " %-18s %-17s %-12s %s\n",
m.st.subtleStyle.Render("TIME"), subtleStyle.Render("TIME"),
m.st.subtleStyle.Render("TRANSITION"), subtleStyle.Render("TRANSITION"),
m.st.subtleStyle.Render("DURATION"), subtleStyle.Render("DURATION"),
m.st.subtleStyle.Render("REASON")) subtleStyle.Render("REASON"))
if len(m.historyChanges) == 0 { if len(m.historyChanges) == 0 {
b.WriteString("\n " + m.st.subtleStyle.Render("No state changes recorded") + "\n") b.WriteString("\n " + subtleStyle.Render("No state changes recorded") + "\n")
} else { } else {
b.WriteString(m.historyViewport.View()) b.WriteString(m.historyViewport.View())
} }
b.WriteString("\n" + m.divider() + "\n") b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
stats := computeHistoryStats(m.historyChanges) stats := computeHistoryStats(m.historyChanges)
parts := []string{fmt.Sprintf("%d events", stats.totalEvents)} parts := []string{fmt.Sprintf("%d events", stats.totalEvents)}
@@ -185,8 +181,8 @@ func (m Model) viewHistoryPanel() string {
avg := stats.totalDowntime / time.Duration(stats.outageCount) avg := stats.totalDowntime / time.Duration(stats.outageCount)
parts = append(parts, "avg outage "+fmtDuration(avg)) parts = append(parts, "avg outage "+fmtDuration(avg))
} }
b.WriteString(" " + m.st.subtleStyle.Render(strings.Join(parts, " │ ")) + "\n") b.WriteString(" " + subtleStyle.Render(strings.Join(parts, " │ ")) + "\n")
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
+4 -4
View File
@@ -134,14 +134,14 @@ func TestComputeHistoryStats_Empty(t *testing.T) {
func TestStateChangeSparkline(t *testing.T) { func TestStateChangeSparkline(t *testing.T) {
t.Run("empty", func(t *testing.T) { t.Run("empty", func(t *testing.T) {
if got := styledModel.stateChangeSparkline(nil, 20); got != "" { if got := stateChangeSparkline(nil, 20); got != "" {
t.Errorf("expected empty for nil, got %q", got) t.Errorf("expected empty for nil, got %q", got)
} }
}) })
t.Run("single event", func(t *testing.T) { t.Run("single event", func(t *testing.T) {
changes := []models.StateChange{{ChangedAt: time.Now()}} changes := []models.StateChange{{ChangedAt: time.Now()}}
if got := styledModel.stateChangeSparkline(changes, 20); got != "" { if got := stateChangeSparkline(changes, 20); got != "" {
t.Errorf("expected empty for single event, got %q", got) t.Errorf("expected empty for single event, got %q", got)
} }
}) })
@@ -152,7 +152,7 @@ func TestStateChangeSparkline(t *testing.T) {
{ChangedAt: now}, {ChangedAt: now},
{ChangedAt: now.Add(-1 * time.Hour)}, {ChangedAt: now.Add(-1 * time.Hour)},
} }
got := styledModel.stateChangeSparkline(changes, 20) got := stateChangeSparkline(changes, 20)
if got == "" { if got == "" {
t.Error("expected non-empty sparkline for two events") t.Error("expected non-empty sparkline for two events")
} }
@@ -164,7 +164,7 @@ func TestStateChangeSparkline(t *testing.T) {
{ChangedAt: now}, {ChangedAt: now},
{ChangedAt: now.Add(-1 * time.Hour)}, {ChangedAt: now.Add(-1 * time.Hour)},
} }
if got := styledModel.stateChangeSparkline(changes, 3); got != "" { if got := stateChangeSparkline(changes, 3); got != "" {
t.Errorf("expected empty for width 3, got %q", got) t.Errorf("expected empty for width 3, got %q", got)
} }
}) })
+36 -30
View File
@@ -24,57 +24,63 @@ var slaPeriods = []struct {
func (m Model) viewSLAPanel() string { func (m Model) viewSLAPanel() string {
var b strings.Builder var b strings.Builder
header := " " + m.st.titleStyle.Render("SLA REPORT: "+m.slaSiteName) header := " " + titleStyle.Render("SLA REPORT: "+m.slaSiteName)
header += " " + m.st.subtleStyle.Render("[q] Back") header += " " + subtleStyle.Render("[q] Back")
b.WriteString(header + "\n") b.WriteString(header + "\n")
b.WriteString(m.divider() + "\n")
divWidth := m.termWidth - chromePadH - 4
if divWidth < 40 {
divWidth = 40
}
b.WriteString(" " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
period := slaPeriods[m.slaPeriodIdx] period := slaPeriods[m.slaPeriodIdx]
b.WriteString(" " + m.st.subtleStyle.Render("Period: Last "+period.label) + "\n\n") b.WriteString(" " + subtleStyle.Render("Period: Last "+period.label) + "\n\n")
r := m.slaReport r := m.slaReport
barWidth := m.dividerWidth() - 30 // Uptime bar
barWidth := divWidth - 30
if barWidth < 10 { if barWidth < 10 {
barWidth = 10 barWidth = 10
} }
bar := m.uptimeBar(r.UptimePct, barWidth) bar := uptimeBar(r.UptimePct, barWidth)
uptimeColor := m.st.specialStyle uptimeColor := specialStyle
if r.UptimePct < 99.9 { if r.UptimePct < 99.9 {
uptimeColor = m.st.warnStyle uptimeColor = warnStyle
} }
if r.UptimePct < 99.0 { if r.UptimePct < 99.0 {
uptimeColor = m.st.dangerStyle uptimeColor = dangerStyle
} }
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar) fmt.Fprintf(&b, " %-14s %s %s\n", subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Downtime"), fmtDuration(r.Downtime)) fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Downtime"), fmtDuration(r.Downtime))
fmt.Fprintf(&b, " %-16s %d\n", m.st.subtleStyle.Render("Outages"), r.OutageCount) fmt.Fprintf(&b, " %-14s %d\n", subtleStyle.Render("Outages"), r.OutageCount)
if r.OutageCount > 0 { if r.OutageCount > 0 {
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Longest"), fmtDuration(r.LongestOut)) fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("Longest"), fmtDuration(r.LongestOut))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTTR"), fmtDuration(r.MTTR)) fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTTR"), fmtDuration(r.MTTR))
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("MTBF"), fmtDuration(r.MTBF)) fmt.Fprintf(&b, " %-14s %s\n", subtleStyle.Render("MTBF"), fmtDuration(r.MTBF))
} }
b.WriteString("\n" + m.divider() + "\n") b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
if len(m.slaDailyBreakdown) > 0 { if len(m.slaDailyBreakdown) > 0 {
b.WriteString(m.slaViewport.View()) b.WriteString(m.slaViewport.View())
} }
b.WriteString("\n" + m.divider() + "\n") b.WriteString("\n " + subtleStyle.Render(strings.Repeat("─", divWidth)) + "\n")
var keys []string var keys []string
for i, p := range slaPeriods { for i, p := range slaPeriods {
label := fmt.Sprintf("[%s] %s", p.key, p.label) label := fmt.Sprintf("[%s] %s", p.key, p.label)
if i == m.slaPeriodIdx { if i == m.slaPeriodIdx {
keys = append(keys, m.st.titleStyle.Render(label)) keys = append(keys, titleStyle.Render(label))
} else { } else {
keys = append(keys, m.st.subtleStyle.Render(label)) keys = append(keys, subtleStyle.Render(label))
} }
} }
b.WriteString(" " + strings.Join(keys, " ")) b.WriteString(" " + strings.Join(keys, " "))
b.WriteString(" " + m.st.subtleStyle.Render("[j/k/↑/↓] Scroll [q/Esc] Back")) b.WriteString(" " + subtleStyle.Render("[j/k/↑/↓] Scroll"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String()) return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
} }
@@ -82,32 +88,32 @@ func (m Model) viewSLAPanel() string {
func (m Model) buildSLADailyContent() string { func (m Model) buildSLADailyContent() string {
var b strings.Builder var b strings.Builder
barWidth := m.dividerWidth() - 30 barWidth := m.termWidth - chromePadH - 30
if barWidth < 10 { if barWidth < 10 {
barWidth = 10 barWidth = 10
} }
b.WriteString(" " + m.st.subtleStyle.Render("DAILY BREAKDOWN") + "\n") b.WriteString(" " + subtleStyle.Render("DAILY BREAKDOWN") + "\n")
for _, day := range m.slaDailyBreakdown { for _, day := range m.slaDailyBreakdown {
dateStr := day.Date.Format("Jan 02") dateStr := day.Date.Format("Jan 02")
bar := m.uptimeBar(day.UptimePct, barWidth) bar := uptimeBar(day.UptimePct, barWidth)
pctStr := fmtPct(day.UptimePct) + "%" pctStr := fmtPct(day.UptimePct) + "%"
color := m.st.specialStyle color := specialStyle
if day.UptimePct < 99.9 { if day.UptimePct < 99.9 {
color = m.st.warnStyle color = warnStyle
} }
if day.UptimePct < 99.0 { if day.UptimePct < 99.0 {
color = m.st.dangerStyle color = dangerStyle
} }
fmt.Fprintf(&b, " %-8s %s %s\n", m.st.subtleStyle.Render(dateStr), bar, color.Render(pctStr)) fmt.Fprintf(&b, " %-8s %s %s\n", subtleStyle.Render(dateStr), bar, color.Render(pctStr))
} }
return b.String() return b.String()
} }
func (m Model) uptimeBar(pct float64, width int) string { func uptimeBar(pct float64, width int) string {
filled := int(math.Round(pct / 100 * float64(width))) filled := int(math.Round(pct / 100 * float64(width)))
if filled > width { if filled > width {
filled = width filled = width
@@ -117,9 +123,9 @@ func (m Model) uptimeBar(pct float64, width int) string {
} }
empty := width - filled empty := width - filled
bar := m.st.specialStyle.Render(strings.Repeat("█", filled)) bar := specialStyle.Render(strings.Repeat("█", filled))
if empty > 0 { if empty > 0 {
bar += m.st.subtleStyle.Render(strings.Repeat("░", empty)) bar += subtleStyle.Render(strings.Repeat("░", empty))
} }
return bar return bar
} }