25 Commits

Author SHA1 Message Date
lerko 1fa2b1d98c docs: add install instructions and Kuma migration guide to README
CI / test (pull_request) Successful in 4m36s
CI / lint (pull_request) Successful in 1m2s
2026-05-24 15:33:13 -04:00
lerko 09e1bec9a3 docs: add SECURITY.md with disclosure policy 2026-05-24 14:15:25 -04:00
lerko deb7d017af docs: add CONTRIBUTING.md 2026-05-24 14:15:11 -04:00
lerko 1e0ae22447 docs: add CHANGELOG.md with release history 2026-05-24 14:14:57 -04:00
lerko 611f26846c chore: update LICENSE with dual copyright for independent fork 2026-05-24 14:14:35 -04:00
lerko 8f9210b451 feat: add --version flag with build metadata injection
Supports `goupkeep version`, `--version`, and `-v`. Prints version,
commit hash, and build date when injected via ldflags. Shows "dev"
for local builds. Dockerfile updated with ARGs for version injection.
2026-05-24 14:14:13 -04:00
lerko cc8d76fdbc Merge pull request 'chore: add linter config and CI pipeline' (#20) from chore/linter-ci-pipeline into main
CI / test (push) Successful in 4m52s
CI / lint (push) Successful in 1m11s
Reviewed-on: lerko/uptime#20
2026-05-24 17:56:05 +00:00
lerko 26268bb6ef fix(ci): install gcc for race detector support
CI / test (pull_request) Successful in 4m41s
CI / lint (pull_request) Successful in 1m17s
2026-05-24 12:49:21 -04:00
lerko 5915e0ebe3 fix(ci): enable CGO for race detector, use lint-action v7
CI / test (pull_request) Failing after 1m27s
CI / lint (pull_request) Successful in 1m37s
2026-05-24 12:45:28 -04:00
lerko 6d7ecc46eb fix(ci): use sh instead of bash for runner compatibility
CI / test (pull_request) Failing after 46s
CI / lint (pull_request) Failing after 20s
2026-05-24 12:42:49 -04:00
lerko fb3f96f608 ci: add Gitea Actions pipeline for test and lint
CI / test (pull_request) Failing after 9s
CI / lint (pull_request) Failing after 56s
Runs on push to main and on pull requests. Two parallel jobs:
- test: go vet + go test -race
- lint: golangci-lint via official action
2026-05-23 22:02:26 -04:00
lerko 359cff7292 chore: add golangci-lint config and fix all lint issues
Add .golangci.yml enabling errcheck, staticcheck, govet, gosec,
ineffassign, and unused linters. Fix 66 issues across 16 files:
- Check all unchecked errors (errcheck)
- Use HTTP status constants instead of numeric literals (staticcheck)
- Replace deprecated LineUp/LineDown with ScrollUp/ScrollDown (staticcheck)
- Convert sprintf+write patterns to fmt.Fprintf (staticcheck)
- Add ReadHeaderTimeout to http.Server (gosec)
- Remove unused types and functions (unused)
- Add nolint comments for intentional patterns (InsecureSkipVerify,
  math/rand for jitter, dialect-only SQL formatting)
2026-05-23 22:02:06 -04:00
lerko da61ce0f88 Merge pull request 'fix: critical bugs and security hardening' (#19) from fix/critical-bugs-security-hardening into main 2026-05-24 01:45:11 +00:00
lerko 7398f520f0 test(cluster): add tests for follower failover and probe operations
15 tests covering leader/follower mode selection, follower failover
after 3 consecutive health check failures, recovery when leader returns,
secret header propagation, context cancellation, probe registration,
assignment fetching, concurrent check execution (verifies 10-semaphore
cap), and result reporting.
2026-05-23 21:23:26 -04:00
lerko c6d120d7a4 test(server): add HTTP handler tests for all API endpoints
24 tests covering push heartbeat, health check, backup export/import,
probe registration/assignments/results, and status page endpoints.
Tests verify auth enforcement (constant-time secret), method validation,
input validation, token stripping on status JSON, and maintenance
window overrides.
2026-05-23 21:10:32 -04:00
lerko 94296e8286 test(monitor): add comprehensive test suite for engine and checkers
55 tests covering state machine transitions, heartbeat handling, push
deadline checks, group aggregation, history recording, probe aggregation,
log management, state management, and concurrency safety.

Checker tests cover HTTP (via httptest), port (via net.Listen),
isCodeAccepted ranges, and siteTimeout defaults. Ping and DNS
checkers skipped (need ICMP privileges and DNS server).

Coverage: 64.2% overall, 100% on handleStatusChange, triggerAlert,
checkPush, recordCheck, and AggregateStatus.
2026-05-23 21:06:28 -04:00
lerko 4b5495fb49 fix(monitor): add jitter to check intervals and stagger startup
Monitors with the same interval no longer fire simultaneously.
Each tick adds up to 10% random jitter. Initial checks stagger
over 0-3s to avoid thundering herd on startup.
2026-05-23 20:05:30 -04:00
lerko 4891843c94 fix: graceful shutdown for HTTP, SSH servers and database
HTTP and SSH servers now shut down cleanly on SIGINT/SIGTERM with a
30s timeout. Database connection closed via defer. Replaced log.Fatalf
in SSH goroutine with log.Printf + ErrServerClosed check to prevent
unclean process exits.
2026-05-23 13:23:27 -04:00
lerko 93c5b638cf fix(server): constant-time secret comparison, request size limits
Replace string equality checks on cluster secret with
crypto/subtle.ConstantTimeCompare to prevent timing attacks.
Add http.MaxBytesReader (1MB) to all POST endpoints that decode
JSON bodies. Change Start() to return *http.Server for graceful
shutdown support. Replace log.Fatalf with log.Printf in HTTP
server goroutine.
2026-05-23 13:20:28 -04:00
lerko 8e6d97710b fix(alert): add context to Provider.Send, log alert failures
Provider.Send now accepts context.Context for timeout/cancellation.
HTTPProvider and NtfyProvider use NewRequestWithContext so HTTP alerts
respect the 30s deadline. triggerAlert logs send failures and config
load errors instead of silently swallowing them.
2026-05-23 13:18:04 -04:00
lerko ae141c62ba fix(store): replace panic with error return, handle unmarshal errors
generateToken() now returns (string, error) instead of panicking on
crypto/rand failure. All json.Unmarshal calls for alert settings now
check and propagate errors instead of silently ignoring them.

Adds Close() to Store interface for graceful shutdown support.
Skips malformed notification entries during Kuma import.
2026-05-23 13:15:39 -04:00
lerko ba53845193 Merge pull request 'fix(tui): visual polish and layout improvements' (#18) from fix/tui-visual-polish into main
Reviewed-on: lerko/uptime#18
2026-05-23 16:12:57 +00:00
lerko fb11e9ba85 fix(tui): stable monitor count and universal group icons
Site count in tab label and footer now reflects total monitors
(excluding groups) regardless of collapse state. Down count also
computed from all sites so collapsed groups with down children
still surface in the badge. Replaced Nerd Font folder glyphs
with standard Unicode triangles for cross-font compatibility.
2026-05-23 11:01:34 -04:00
lerko e84b64f8ed feat(tui): zebra striping, detail breadcrumb, sparkline stats, collapse persistence
Add alternating row backgrounds for easier table scanning. Detail panel
now shows breadcrumb path (Sites > Group > Name) and min/avg/max latency
stats below the sparkline. Group collapse state persists across restarts
via new preferences table in both SQLite and Postgres.
2026-05-22 20:53:23 -04:00
lerko 88e4f0ed69 fix(tui): group selection highlight, layout constants, group history graphs
Group rows now show selection background when navigated to. Layout
chrome extracted to named constants to prevent viewport drift. Groups
display aggregate history as dot sparkline (●) distinct from site
bar sparklines, with uptime computed from active children only.
Paused and maintenance children excluded from group aggregates.
2026-05-22 20:26:49 -04:00
32 changed files with 2935 additions and 200 deletions
+41
View File
@@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Install build tools
run: apk add --no-cache gcc musl-dev
- name: Vet
run: go vet ./...
- name: Test
run: CGO_ENABLED=1 go test -race -timeout 120s ./...
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24"
- uses: golangci/golangci-lint-action@v7
with:
version: v2.11.2
+29
View File
@@ -0,0 +1,29 @@
version: "2"
linters:
default: none
enable:
- errcheck
- staticcheck
- govet
- gosec
- ineffassign
- unused
settings:
errcheck:
check-type-assertions: false
check-blank: false
exclusions:
presets:
- std-error-handling
- common-false-positives
rules:
- path: _test\.go
linters:
- errcheck
- gosec
run:
timeout: 5m
+46
View File
@@ -0,0 +1,46 @@
# Changelog
## [2026.05.2] — 2026-05-23
### Added
- Comprehensive test suite (94 tests across monitor, server, cluster)
- golangci-lint config with CI enforcement
- Gitea Actions CI pipeline (test + lint)
- Graceful shutdown for HTTP and SSH servers
- Context-aware alert delivery with timeout
- Request size limits on all POST endpoints
- Constant-time secret comparison
- Check interval jitter to prevent thundering herd
- `--version` flag with build metadata injection
### Fixed
- Silent JSON unmarshal failures in alert settings
- Panic on crypto/rand failure replaced with error return
- Alert delivery errors now logged instead of swallowed
- log.Fatalf in goroutines replaced with log.Printf
- Deprecated LineUp/LineDown API calls
### Security
- Cluster secret compared with crypto/subtle (timing-safe)
- http.MaxBytesReader on all JSON endpoints
- ReadHeaderTimeout added to HTTP server
## [2026.05.1] — 2026-05-14
### Added
- Distributed probing with leader + probe nodes
- Config-as-code (YAML apply/export with dry-run, prune)
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
- Incident management and maintenance windows
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
## [2026.04.1] — Initial independent fork
### Added
- SSH-accessible TUI (Bubble Tea + Wish)
- 6 check types (HTTP, Push, Ping, Port, DNS, Group)
- SQLite and PostgreSQL support
- HA clustering with automatic failover
- Prometheus metrics endpoint
- Public status page
- Uptime Kuma import
+23
View File
@@ -0,0 +1,23 @@
# Contributing
## Development
```sh
go run cmd/goupkeep/main.go -demo # starts with sample data
ssh -p 23234 localhost # connect to TUI
```
## Tests
```sh
go test ./... # unit tests
go test -race ./... # race detector
golangci-lint run ./... # linting
```
## Pull Requests
- Branch from `main`, PR back to `main`
- Conventional Commits for messages (`feat:`, `fix:`, `chore:`)
- Tests must pass, linter must be clean
- One logical change per PR
+4 -1
View File
@@ -6,7 +6,10 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
RUN go build -ldflags="-s -w" -o go-upkeep ./cmd/goupkeep/main.go ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o go-upkeep ./cmd/goupkeep/main.go
# --- Stage 2: Runner --- # --- Stage 2: Runner ---
FROM alpine:latest FROM alpine:latest
+1
View File
@@ -1,6 +1,7 @@
MIT License MIT License
Copyright (c) 2026 Roman Dvořák Copyright (c) 2026 Roman Dvořák
Copyright (c) 2026 Tyler Koenig
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+31 -1
View File
@@ -2,7 +2,7 @@
Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`. Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`.
Originally forked from [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). This is an independent fork with significant additions. Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
## What it does ## What it does
@@ -28,6 +28,25 @@ Seed some demo data to see it in action:
go run cmd/goupkeep/main.go -demo go run cmd/goupkeep/main.go -demo
``` ```
## Install
### From source
```bash
go install gitea.lerkolabs.com/lerko/uptime/cmd/goupkeep@latest
```
### Docker
```bash
docker pull lerko/go-upkeep:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/go-upkeep
```
### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases).
## Config as code ## Config as code
Export your current monitors: Export your current monitors:
@@ -85,6 +104,17 @@ First run: attach to the container (`docker attach go-upkeep`), go to the Users
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth | | `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | | `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
## Migrating from Uptime Kuma
Export your Kuma backup JSON, then:
```bash
curl -X POST http://localhost:8080/api/import/kuma \
-H "X-Upkeep-Secret: your-secret" \
-H "Content-Type: application/json" \
-d @kuma-backup.json
```
## License ## License
MIT — see [LICENSE](LICENSE). MIT — see [LICENSE](LICENSE).
+19
View File
@@ -0,0 +1,19 @@
# Security Policy
## Reporting a Vulnerability
If you find a security issue, please email security@lerkolabs.com rather than opening a public issue.
Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
We'll acknowledge within 48 hours and aim to patch within 7 days for critical issues.
## Scope
- SSH server authentication
- Cluster API authentication
- Stored credentials (alert provider tokens)
- Status page information leakage
+74 -23
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"go-upkeep/internal/cluster" "go-upkeep/internal/cluster"
@@ -17,6 +18,7 @@ import (
"os/signal" "os/signal"
"strconv" "strconv"
"syscall" "syscall"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
@@ -25,6 +27,12 @@ import (
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
) )
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() { func main() {
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
@@ -36,11 +44,22 @@ func main() {
case "export": case "export":
runExport(os.Args[2:]) runExport(os.Args[2:])
return return
case "version", "--version", "-v":
printVersion()
return
} }
} }
runServe(os.Args[1:]) runServe(os.Args[1:])
} }
func printVersion() {
if version == "dev" {
fmt.Println("go-upkeep dev")
} else {
fmt.Printf("go-upkeep %s (%s, %s)\n", version, commit, date)
}
}
func envOrDefault(key, fallback string) string { func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v
@@ -74,7 +93,7 @@ func runApply(args []string) {
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args) _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
if *filePath == "" { if *filePath == "" {
fmt.Fprintln(os.Stderr, "error: -f flag is required") fmt.Fprintln(os.Stderr, "error: -f flag is required")
@@ -107,7 +126,7 @@ func runExport(args []string) {
outPath := fs.String("o", "-", "Output file path (- for stdout)") outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args) _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn) s := openStore(*dbType, *dsn)
@@ -184,6 +203,7 @@ func runServe(args []string) {
fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, 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()
done := make(chan os.Signal, 1) done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
@@ -210,7 +230,7 @@ func runServe(args []string) {
flagDSN := fs.String("dsn", 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) _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
var s store.Store var s store.Store
var dbErr error var dbErr error
@@ -225,6 +245,7 @@ func runServe(args []string) {
fmt.Printf("Database connection error: %v\n", dbErr) fmt.Printf("Database connection error: %v\n", dbErr)
os.Exit(1) os.Exit(1)
} }
defer s.Close()
if err := s.Init(); err != nil { if err := s.Init(); err != nil {
fmt.Printf("Database init error: %v\n", err) fmt.Printf("Database init error: %v\n", err)
@@ -263,7 +284,7 @@ func runServe(args []string) {
eng.InitLogs() eng.InitLogs()
eng.Start(ctx) eng.Start(ctx)
server.Start(server.ServerConfig{ httpSrv := server.Start(server.ServerConfig{
Port: httpPort, Port: httpPort,
EnableStatus: enableStatus, EnableStatus: enableStatus,
Title: statusTitle, Title: statusTitle,
@@ -276,7 +297,7 @@ func runServe(args []string) {
SharedKey: clusterKey, SharedKey: clusterKey,
}, eng) }, eng)
startSSHServer(*port, s, eng) sshSrv := startSSHServer(*port, s, eng)
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
@@ -291,9 +312,22 @@ func runServe(args []string) {
fmt.Println("Shutting down...") fmt.Println("Shutting down...")
} }
cancel() cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if httpSrv != nil {
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown error: %v", err)
}
}
if sshSrv != nil {
if err := sshSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("SSH shutdown error: %v", err)
}
}
} }
func startSSHServer(port int, db store.Store, eng *monitor.Engine) { func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server {
s, err := wish.NewServer( s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithHostKeyPath(".ssh/id_ed25519"),
@@ -308,13 +342,14 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine) {
) )
if err != nil { if err != nil {
fmt.Printf("SSH server error: %v\n", err) fmt.Printf("SSH server error: %v\n", err)
return return nil
} }
go func() { go func() {
if err := s.ListenAndServe(); err != nil { if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Fatalf("SSH server failed: %v", err) log.Printf("SSH server error: %v", err)
} }
}() }()
return s
} }
func seedDemoData(s store.Store) { func seedDemoData(s store.Store) {
@@ -324,13 +359,22 @@ func seedDemoData(s store.Store) {
} }
fmt.Println("Seeding demo data...") fmt.Println("Seeding demo data...")
s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}) if err := s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}); err != nil {
s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}) log.Printf("demo seed: add alert: %v", err)
s.AddAlert("Email Oncall", "email", map[string]string{ return
}
if err := s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}); err != nil {
log.Printf("demo seed: add alert: %v", err)
return
}
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 {
log.Printf("demo seed: add alert: %v", err)
return
}
alerts, _ := s.GetAllAlerts() alerts, _ := s.GetAllAlerts()
alertID := 0 alertID := 0
@@ -338,16 +382,23 @@ func seedDemoData(s store.Store) {
alertID = alerts[0].ID alertID = alerts[0].ID
} }
s.AddSite(models.Site{Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2}) demoSites := []models.Site{
s.AddSite(models.Site{Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3}) {Name: "Google", URL: "https://www.google.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 14, MaxRetries: 2},
s.AddSite(models.Site{Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1}) {Name: "GitHub", URL: "https://github.com", Type: "http", Interval: 30, AlertID: alertID, CheckSSL: true, ExpiryThreshold: 7, MaxRetries: 3},
s.AddSite(models.Site{Name: "JSON Placeholder", URL: "https://jsonplaceholder.typicode.com/posts/1", Type: "http", Interval: 45, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 2}) {Name: "Cloudflare DNS", URL: "https://1.1.1.1", Type: "http", Interval: 60, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 1},
s.AddSite(models.Site{Name: "Nonexistent Site", URL: "https://this-domain-does-not-exist-12345.com", Type: "http", Interval: 30, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 3}) {Name: "JSON Placeholder", URL: "https://jsonplaceholder.typicode.com/posts/1", Type: "http", Interval: 45, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 2},
s.AddSite(models.Site{Name: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1}) {Name: "Nonexistent Site", URL: "https://this-domain-does-not-exist-12345.com", Type: "http", Interval: 30, AlertID: alertID, ExpiryThreshold: 7, MaxRetries: 3},
s.AddSite(models.Site{Name: "Backup Cron", Type: "push", Interval: 300, AlertID: alertID, ExpiryThreshold: 7}) {Name: "Bad Port", URL: "https://localhost:19999", Type: "http", Interval: 30, ExpiryThreshold: 7, MaxRetries: 1},
s.AddSite(models.Site{Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, ExpiryThreshold: 7}) {Name: "Backup Cron", Type: "push", Interval: 300, AlertID: alertID, ExpiryThreshold: 7},
s.AddSite(models.Site{Name: "Gateway", Type: "ping", Interval: 30, AlertID: alertID, Hostname: "10.0.0.1", Timeout: 5, ExpiryThreshold: 7}) {Name: "DB Healthcheck", Type: "push", Interval: 120, AlertID: alertID, ExpiryThreshold: 7},
s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, ExpiryThreshold: 7}) {Name: "Gateway", Type: "ping", Interval: 30, AlertID: alertID, Hostname: "10.0.0.1", 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 {
if err := s.AddSite(site); err != nil {
log.Printf("demo seed: add site %q: %v", site.Name, err)
}
}
} }
func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
+17 -6
View File
@@ -2,6 +2,7 @@ package alert
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
@@ -15,7 +16,7 @@ import (
var alertClient = &http.Client{Timeout: 10 * time.Second} var alertClient = &http.Client{Timeout: 10 * time.Second}
type Provider interface { type Provider interface {
Send(title, message string) error Send(ctx context.Context, title, message string) error
} }
type PayloadFunc func(title, message string) ([]byte, error) type PayloadFunc func(title, message string) ([]byte, error)
@@ -25,12 +26,17 @@ type HTTPProvider struct {
Payload PayloadFunc Payload PayloadFunc
} }
func (h *HTTPProvider) Send(title, message string) error { func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
body, err := h.Payload(title, message) body, err := h.Payload(title, message)
if err != nil { if err != nil {
return err return err
} }
resp, err := alertClient.Post(h.URL, "application/json", bytes.NewBuffer(body)) req, err := http.NewRequestWithContext(ctx, "POST", h.URL, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return err return err
} }
@@ -170,7 +176,12 @@ type EmailProvider struct {
Host, Port, User, Pass, To, From string Host, Port, User, Pass, To, From string
} }
func (e *EmailProvider) Send(title, message string) error { func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
msg := []byte("To: " + e.To + "\r\n" + msg := []byte("To: " + e.To + "\r\n" +
"Subject: Go-Upkeep: " + title + "\r\n" + "Subject: Go-Upkeep: " + title + "\r\n" +
@@ -187,9 +198,9 @@ type NtfyProvider struct {
Password string Password string
} }
func (n *NtfyProvider) Send(title, message string) error { func (n *NtfyProvider) Send(ctx context.Context, title, message string) error {
url := strings.TrimRight(n.ServerURL, "/") + "/" + n.Topic url := strings.TrimRight(n.ServerURL, "/") + "/" + n.Topic
req, err := http.NewRequest("POST", url, strings.NewReader(message)) req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(message))
if err != nil { if err != nil {
return err return err
} }
+10 -9
View File
@@ -1,6 +1,7 @@
package alert package alert
import ( import (
"context"
"encoding/json" "encoding/json"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"net/http" "net/http"
@@ -17,7 +18,7 @@ func TestHTTPProviderDiscord(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Test Title", "Test Body"); err != nil { if err := p.Send(context.Background(), "Test Title", "Test Body"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
@@ -35,7 +36,7 @@ func TestHTTPProviderSlack(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}}) p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Alert", "Message"); err != nil { if err := p.Send(context.Background(), "Alert", "Message"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
@@ -53,7 +54,7 @@ func TestHTTPProviderWebhook(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}}) p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Title", "Body"); err != nil { if err := p.Send(context.Background(), "Title", "Body"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
@@ -69,7 +70,7 @@ func TestHTTPProviderErrorOnHTTP4xx(t *testing.T) {
defer srv.Close() defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}}) p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Test", "Test"); err == nil { if err := p.Send(context.Background(), "Test", "Test"); err == nil {
t.Fatal("expected error on 403 response") t.Fatal("expected error on 403 response")
} }
} }
@@ -89,7 +90,7 @@ func TestNtfyProvider(t *testing.T) {
"url": srv.URL, "url": srv.URL,
"topic": "test", "topic": "test",
}}) }})
if err := p.Send("Alert Title", "Alert Body"); err != nil { if err := p.Send(context.Background(), "Alert Title", "Alert Body"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
@@ -110,7 +111,7 @@ func TestHTTPProviderTelegram(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")} p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")}
if err := p.Send("Alert", "Down"); err != nil { if err := p.Send(context.Background(), "Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
if received["chat_id"] != "12345" { if received["chat_id"] != "12345" {
@@ -133,7 +134,7 @@ func TestHTTPProviderPagerDuty(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")} p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")}
if err := p.Send("Alert", "Down"); err != nil { if err := p.Send(context.Background(), "Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
if received["routing_key"] != "test-key" { if received["routing_key"] != "test-key" {
@@ -160,7 +161,7 @@ func TestHTTPProviderPushover(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")} p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")}
if err := p.Send("Alert", "Down"); err != nil { if err := p.Send(context.Background(), "Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
if received["token"] != "app-tok" { if received["token"] != "app-tok" {
@@ -183,7 +184,7 @@ func TestHTTPProviderGotify(t *testing.T) {
defer srv.Close() defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")} p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")}
if err := p.Send("Alert", "Down"); err != nil { if err := p.Send(context.Background(), "Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
} }
if received["title"] != "Alert" || received["message"] != "Down" { if received["title"] != "Alert" || received["message"] != "Down" {
+1 -1
View File
@@ -59,7 +59,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
if err == nil && resp.StatusCode == 200 { if err == nil && resp.StatusCode == 200 {
isLeaderHealthy = true isLeaderHealthy = true
resp.Body.Close() _ = resp.Body.Close()
} }
if isLeaderHealthy { if isLeaderHealthy {
+394
View File
@@ -0,0 +1,394 @@
package cluster
import (
"context"
"encoding/json"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// --- Mock Store (minimal, for monitor.NewEngine) ---
type mockStore struct {
sites []models.Site
}
func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return nil, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) { return nil, nil }
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) Close() error { return nil }
// --- Cluster Start Tests ---
func TestStart_LeaderMode(t *testing.T) {
eng := monitor.NewEngine(&mockStore{})
eng.SetActive(false)
ctx := context.Background()
Start(ctx, Config{Mode: "leader"}, eng)
if !eng.IsActive() {
t.Error("leader mode should set engine active")
}
}
func TestStart_FollowerMode(t *testing.T) {
eng := monitor.NewEngine(&mockStore{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Start(ctx, Config{Mode: "follower", PeerURL: "http://localhost:9999"}, eng)
time.Sleep(50 * time.Millisecond)
if eng.IsActive() {
t.Error("follower mode should set engine inactive")
}
}
// --- Follower Loop Tests ---
func TestFollowerLoop_FailoverOnLeaderDown(t *testing.T) {
eng := monitor.NewEngine(&mockStore{})
eng.SetActive(false)
// Server always returns 503
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runFollowerLoop(ctx, Config{PeerURL: srv.URL, SharedKey: "key"}, eng)
// Follower checks every 5s, needs 3 failures → ~15s minimum
// But we can't wait that long in a test. The loop sleeps 5s between checks.
// We'll wait up to 20s for failover.
deadline := time.After(20 * time.Second)
for {
if eng.IsActive() {
return // success
}
select {
case <-deadline:
t.Fatal("expected failover to ACTIVE after 3 failures")
case <-time.After(500 * time.Millisecond):
}
}
}
func TestFollowerLoop_RecoveryOnLeaderReturn(t *testing.T) {
eng := monitor.NewEngine(&mockStore{})
eng.SetActive(true) // simulate already failed over
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte("OK"))
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runFollowerLoop(ctx, Config{PeerURL: srv.URL}, eng)
deadline := time.After(10 * time.Second)
for {
if !eng.IsActive() {
return // success — switched back to passive
}
select {
case <-deadline:
t.Fatal("expected switch back to PASSIVE when leader returns")
case <-time.After(500 * time.Millisecond):
}
}
}
func TestFollowerLoop_SendsSecret(t *testing.T) {
var mu sync.Mutex
var receivedSecret string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
receivedSecret = r.Header.Get("X-Upkeep-Secret")
mu.Unlock()
w.WriteHeader(200)
w.Write([]byte("OK"))
}))
defer srv.Close()
eng := monitor.NewEngine(&mockStore{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runFollowerLoop(ctx, Config{PeerURL: srv.URL, SharedKey: "test-secret"}, eng)
deadline := time.After(10 * time.Second)
for {
mu.Lock()
got := receivedSecret
mu.Unlock()
if got == "test-secret" {
return
}
select {
case <-deadline:
t.Fatalf("expected secret 'test-secret', got %q", got)
case <-time.After(500 * time.Millisecond):
}
}
}
func TestFollowerLoop_CancelContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
eng := monitor.NewEngine(&mockStore{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
runFollowerLoop(ctx, Config{PeerURL: srv.URL}, eng)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("expected follower loop to exit on context cancel")
}
}
// --- Probe Tests ---
func TestProbeRegister_Success(t *testing.T) {
var received map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(200)
}))
defer srv.Close()
err := probeRegister(context.Background(), srv.Client(), ProbeConfig{
NodeID: "n1", NodeName: "US East", Region: "us-east", LeaderURL: srv.URL, SharedKey: "key",
})
if err != nil {
t.Fatalf("register: %v", err)
}
if received["id"] != "n1" {
t.Errorf("expected id n1, got %s", received["id"])
}
}
func TestProbeRegister_Failure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
}))
defer srv.Close()
err := probeRegister(context.Background(), srv.Client(), ProbeConfig{
LeaderURL: srv.URL,
})
if err == nil {
t.Error("expected error on 401")
}
}
func TestProbeFetchAssignments_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string][]models.Site{
"sites": {{ID: 1, Name: "s1", Type: "http", URL: "http://example.com"}},
})
}))
defer srv.Close()
sites, err := probeFetchAssignments(context.Background(), srv.Client(), ProbeConfig{
NodeID: "n1", LeaderURL: srv.URL, SharedKey: "key",
})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(sites) != 1 {
t.Errorf("expected 1 site, got %d", len(sites))
}
}
func TestProbeFetchAssignments_Unauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
}))
defer srv.Close()
_, err := probeFetchAssignments(context.Background(), srv.Client(), ProbeConfig{
LeaderURL: srv.URL,
})
if err == nil {
t.Error("expected error on 401")
}
}
func TestProbeExecuteChecks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
sites := []models.Site{
{ID: 1, Type: "http", URL: srv.URL},
{ID: 2, Type: "http", URL: srv.URL},
}
strict := &http.Client{}
insecure := &http.Client{}
results := probeExecuteChecks(context.Background(), sites, strict, insecure)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for _, r := range results {
if !r.IsUp {
t.Errorf("site %d expected UP", r.SiteID)
}
}
}
func TestProbeExecuteChecks_Concurrency(t *testing.T) {
var concurrent int64
var maxConcurrent int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cur := atomic.AddInt64(&concurrent, 1)
for {
old := atomic.LoadInt64(&maxConcurrent)
if cur <= old || atomic.CompareAndSwapInt64(&maxConcurrent, old, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
atomic.AddInt64(&concurrent, -1)
w.WriteHeader(200)
}))
defer srv.Close()
var sites []models.Site
for i := 0; i < 20; i++ {
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
}
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{})
if len(results) != 20 {
t.Errorf("expected 20 results, got %d", len(results))
}
mc := atomic.LoadInt64(&maxConcurrent)
if mc > 10 {
t.Errorf("expected max 10 concurrent, got %d", mc)
}
}
func TestProbeReportResults_Success(t *testing.T) {
var received struct {
NodeID string `json:"node_id"`
Results []probeResultItem `json:"results"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(200)
}))
defer srv.Close()
err := probeReportResults(context.Background(), srv.Client(), ProbeConfig{
NodeID: "n1", LeaderURL: srv.URL, SharedKey: "key",
}, []probeResultItem{{SiteID: 1, LatencyNs: 5000000, IsUp: true}})
if err != nil {
t.Fatalf("report: %v", err)
}
if received.NodeID != "n1" {
t.Errorf("expected n1, got %s", received.NodeID)
}
if len(received.Results) != 1 {
t.Errorf("expected 1 result, got %d", len(received.Results))
}
}
func TestProbeReportResults_Failure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer srv.Close()
err := probeReportResults(context.Background(), srv.Client(), ProbeConfig{
LeaderURL: srv.URL,
}, []probeResultItem{{SiteID: 1}})
if err == nil {
t.Error("expected error on 500")
}
}
// --- sleepCtx ---
func TestSleepCtx_Cancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
sleepCtx(ctx, 10*time.Second)
if time.Since(start) > time.Second {
t.Error("expected immediate return on canceled context")
}
}
+5 -4
View File
@@ -33,7 +33,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
} }
insecureClient := &http.Client{ insecureClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
} }
if err := probeRegister(ctx, apiClient, cfg); err != nil { if err := probeRegister(ctx, apiClient, cfg); err != nil {
@@ -85,7 +85,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("register returned %d", resp.StatusCode) return fmt.Errorf("register returned %d", resp.StatusCode)
} }
@@ -127,10 +127,11 @@ func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecu
sem := make(chan struct{}, 10) sem := make(chan struct{}, 10)
var wg sync.WaitGroup var wg sync.WaitGroup
loop:
for _, site := range sites { for _, site := range sites {
select { select {
case <-ctx.Done(): case <-ctx.Done():
break break loop
default: default:
} }
wg.Add(1) wg.Add(1)
@@ -171,7 +172,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() _ = resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("results returned %d", resp.StatusCode) return fmt.Errorf("results returned %d", resp.StatusCode)
} }
+1 -1
View File
@@ -142,7 +142,7 @@ func WriteFile(f *File, path string) error {
_, err = os.Stdout.Write(data) _, err = os.Stdout.Write(data)
return err return err
} }
return os.WriteFile(path, data, 0644) return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable
} }
func LoadFile(path string) (*File, error) { func LoadFile(path string) (*File, error) {
+4 -2
View File
@@ -96,7 +96,9 @@ func convertKumaNotifications(entries []KumaNotifEntry) map[int]models.AlertConf
result := make(map[int]models.AlertConfig) result := make(map[int]models.AlertConfig)
for _, entry := range entries { for _, entry := range entries {
var cfg KumaNotifConfig var cfg KumaNotifConfig
json.Unmarshal([]byte(entry.Config), &cfg) if err := json.Unmarshal([]byte(entry.Config), &cfg); err != nil {
continue
}
alert := models.AlertConfig{ alert := models.AlertConfig{
ID: entry.ID, ID: entry.ID,
@@ -175,7 +177,7 @@ func convertKumaMonitor(m KumaMonitor, alertMap map[int]int) models.Site {
for nidStr := range m.NotificationIDs { for nidStr := range m.NotificationIDs {
var nid int var nid int
fmt.Sscanf(nidStr, "%d", &nid) _, _ = fmt.Sscanf(nidStr, "%d", &nid) //nolint:errcheck
if upkeepID, ok := alertMap[nid]; ok { if upkeepID, ok := alertMap[nid]; ok {
site.AlertID = upkeepID site.AlertID = upkeepID
break break
+1 -1
View File
@@ -97,7 +97,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
} }
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Write([]byte(b.String())) _, _ = w.Write([]byte(b.String())) //nolint:errcheck
} }
} }
+3
View File
@@ -62,6 +62,9 @@ func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { retur
func (m *mockStore) EndMaintenanceWindow(int) error { return nil } func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(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) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) Close() error { return nil }
func TestMetricsHandler(t *testing.T) { func TestMetricsHandler(t *testing.T) {
ms := &mockStore{ ms := &mockStore{
+1 -1
View File
@@ -131,7 +131,7 @@ func runPortCheck(site models.Site) CheckResult {
if err != nil { if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
} }
conn.Close() _ = conn.Close()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()} return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
} }
+203
View File
@@ -0,0 +1,203 @@
package monitor
import (
"crypto/tls"
"go-upkeep/internal/models"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
func TestRunCheck_HTTP_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status)
}
if result.StatusCode != 200 {
t.Errorf("expected 200, got %d", result.StatusCode)
}
if result.LatencyNs <= 0 {
t.Error("expected positive latency")
}
}
func TestRunCheck_HTTP_ServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer srv.Close()
site := models.Site{ID: 1, Type: "http", URL: srv.URL}
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status)
}
if result.StatusCode != 500 {
t.Errorf("expected 500, got %d", result.StatusCode)
}
}
func TestRunCheck_HTTP_CustomAcceptedCodes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(302)
}))
defer srv.Close()
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}}
site := models.Site{ID: 1, Type: "http", URL: srv.URL, AcceptedCodes: "200-399"}
result := RunCheck(site, client, client, false)
if result.Status != "UP" {
t.Errorf("expected UP with accepted 200-399, got %s", result.Status)
}
}
func TestRunCheck_HTTP_MethodRespected(t *testing.T) {
var receivedMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedMethod = r.Method
w.WriteHeader(200)
}))
defer srv.Close()
site := models.Site{ID: 1, Type: "http", URL: srv.URL, Method: "HEAD"}
RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if receivedMethod != "HEAD" {
t.Errorf("expected HEAD, got %s", receivedMethod)
}
}
func TestRunCheck_HTTP_Timeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(200)
}))
defer srv.Close()
site := models.Site{ID: 1, Type: "http", URL: srv.URL, Timeout: 1}
result := RunCheck(site, http.DefaultClient, http.DefaultClient, false)
if result.Status != "DOWN" {
t.Errorf("expected DOWN on timeout, got %s", result.Status)
}
}
func TestRunCheck_HTTP_SSLFields(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
insecureClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
site := models.Site{ID: 1, Type: "http", URL: srv.URL, CheckSSL: true, IgnoreTLS: true}
result := RunCheck(site, http.DefaultClient, insecureClient, false)
if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status)
}
if !result.HasSSL {
t.Error("expected HasSSL=true")
}
if result.CertExpiry.IsZero() {
t.Error("expected CertExpiry populated")
}
}
func TestRunCheck_Port_Open(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.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(site, nil, nil, false)
if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status)
}
if result.LatencyNs <= 0 {
t.Error("expected positive latency")
}
}
func TestRunCheck_Port_Closed(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
ln.Close()
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status)
}
}
func TestRunCheck_UnknownType(t *testing.T) {
site := models.Site{ID: 1, Type: "invalid"}
result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" {
t.Errorf("expected DOWN for unknown type, got %s", result.Status)
}
}
func TestIsCodeAccepted(t *testing.T) {
tests := []struct {
code int
accepted string
want bool
}{
{200, "", true},
{299, "", true},
{300, "", false},
{302, "200-399", true},
{400, "200-399", false},
{301, "200,301,404", true},
{500, "200,301,404", false},
{404, "200-299,400-499", true},
{500, "200-299,400-499", false},
}
for _, tt := range tests {
got := isCodeAccepted(tt.code, tt.accepted)
if got != tt.want {
t.Errorf("isCodeAccepted(%d, %q) = %v, want %v", tt.code, tt.accepted, got, tt.want)
}
}
}
func TestSiteTimeout(t *testing.T) {
if got := siteTimeout(models.Site{Timeout: 0}); got != 5*time.Second {
t.Errorf("expected 5s default, got %v", got)
}
if got := siteTimeout(models.Site{Timeout: 10}); got != 10*time.Second {
t.Errorf("expected 10s, got %v", got)
}
}
+17 -5
View File
@@ -7,6 +7,7 @@ import (
"go-upkeep/internal/alert" "go-upkeep/internal/alert"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"math/rand/v2"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@@ -25,7 +26,7 @@ type Engine struct {
histMu sync.RWMutex histMu sync.RWMutex
histories map[int]*SiteHistory histories map[int]*SiteHistory
tokenIndex map[string]int tokenIndex map[string]int // protected by mu
probeResultsMu sync.RWMutex probeResultsMu sync.RWMutex
probeResults map[int]map[string]NodeResult probeResults map[int]map[string]NodeResult
@@ -50,7 +51,7 @@ func NewEngine(s store.Store) *Engine {
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
}, },
insecureClient: &http.Client{ insecureClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
}, },
} }
} }
@@ -277,6 +278,14 @@ func (e *Engine) ToggleSitePause(id int) bool {
} }
func (e *Engine) monitorRoutine(ctx context.Context, id int) { func (e *Engine) monitorRoutine(ctx context.Context, id int) {
// Stagger initial check to avoid thundering herd on startup
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
select {
case <-time.After(stagger):
case <-ctx.Done():
return
}
e.checkByID(id) e.checkByID(id)
for { for {
select { select {
@@ -314,8 +323,9 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if interval < 5 { if interval < 5 {
interval = 5 interval = 5
} }
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select { select {
case <-time.After(time.Duration(interval) * time.Second): case <-time.After(time.Duration(interval)*time.Second + jitter):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -433,6 +443,7 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
func (e *Engine) triggerAlert(alertID int, title, message string) { func (e *Engine) triggerAlert(alertID int, title, message string) {
cfg, err := e.db.GetAlert(alertID) cfg, err := e.db.GetAlert(alertID)
if err != nil { if err != nil {
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
return return
} }
provider := alert.GetProvider(cfg) provider := alert.GetProvider(cfg)
@@ -440,8 +451,9 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
_ = ctx if err := provider.Send(ctx, title, message); err != nil {
_ = provider.Send(title, message) e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
}
}() }()
} }
} }
File diff suppressed because it is too large Load Diff
+61 -45
View File
@@ -1,6 +1,7 @@
package server package server
import ( import (
"crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/importer" "go-upkeep/internal/importer"
@@ -13,8 +14,13 @@ import (
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time"
) )
func checkSecret(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
var statusTpl = template.Must(template.New("status").Parse(` var statusTpl = template.Must(template.New("status").Parse(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -153,7 +159,7 @@ type ServerConfig struct {
ClusterKey string // Shared Secret for Security ClusterKey string // Shared Secret for Security
} }
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" { if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
} }
@@ -163,100 +169,103 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") token := r.URL.Query().Get("token")
if token == "" { if token == "" {
http.Error(w, "Missing token", 400) http.Error(w, "Missing token", http.StatusBadRequest)
return return
} }
if eng.RecordHeartbeat(token) { if eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
} else { } else {
http.Error(w, "Invalid Token", 404) http.Error(w, "Invalid Token", http.StatusNotFound)
} }
}) })
// 2. Health Check (For Cluster Follower) // 2. Health Check (For Cluster Follower)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey != "" && r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
}) })
// 3. Config Export // 3. Config Export
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized)
return return
} }
data, err := s.ExportData() data, err := s.ExportData()
if err != nil { if err != nil {
log.Printf("Export failed: %v", err) log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", 500) http.Error(w, "Export failed", http.StatusInternalServerError)
return return
} }
json.NewEncoder(w).Encode(data) _ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}) })
// 4. Config Import // 4. Config Import
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var data models.Backup var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil { if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if err := s.ImportData(data); err != nil { if err := s.ImportData(data); err != nil {
log.Printf("Import failed: %v", err) log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", http.StatusInternalServerError)
return return
} }
w.Write([]byte("Import Successful")) _, _ = w.Write([]byte("Import Successful"))
}) })
// 5. Kuma Import // 5. Kuma Import
mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var kb importer.KumaBackup var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err) log.Printf("Invalid Kuma JSON: %v", err)
http.Error(w, "Invalid Kuma JSON", 400) http.Error(w, "Invalid Kuma JSON", http.StatusBadRequest)
return return
} }
backup := importer.ConvertKuma(&kb) backup := importer.ConvertKuma(&kb)
if err := s.ImportData(backup); err != nil { if err := s.ImportData(backup); err != nil {
log.Printf("Kuma import failed: %v", err) log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", 500) http.Error(w, "Import failed", http.StatusInternalServerError)
return return
} }
w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}) })
// 6. Probe Registration // 6. Probe Registration
mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct { var req struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -264,27 +273,27 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
Version string `json:"version"` Version string `json:"version"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if req.ID == "" { if req.ID == "" {
http.Error(w, "id is required", 400) http.Error(w, "id is required", http.StatusBadRequest)
return return
} }
if err := s.RegisterNode(models.ProbeNode{ if err := s.RegisterNode(models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version, ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil { }); err != nil {
log.Printf("Probe register failed: %v", err) log.Printf("Probe register failed: %v", err)
http.Error(w, "Registration failed", 500) http.Error(w, "Registration failed", http.StatusInternalServerError)
return return
} }
json.NewEncoder(w).Encode(map[string]bool{"ok": true}) _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}) })
// 7. Probe Assignment Fetch // 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
nodeID := r.URL.Query().Get("node_id") nodeID := r.URL.Query().Get("node_id")
@@ -315,19 +324,20 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
assigned = append(assigned, site) assigned = append(assigned, site)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) _ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}) })
// 8. Probe Result Submission // 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", 405) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
} }
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", 401) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct { var req struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
Results []struct { Results []struct {
@@ -337,11 +347,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
} `json:"results"` } `json:"results"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", http.StatusBadRequest)
return return
} }
if req.NodeID == "" { if req.NodeID == "" {
http.Error(w, "node_id is required", 400) http.Error(w, "node_id is required", http.StatusBadRequest)
return return
} }
for _, result := range req.Results { for _, result := range req.Results {
@@ -350,8 +360,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
} }
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp) eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp)
} }
s.UpdateNodeLastSeen(req.NodeID) if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
json.NewEncoder(w).Encode(map[string]bool{"ok": true}) log.Printf("Failed to update node last seen: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}) })
// 9. Prometheus Metrics // 9. Prometheus Metrics
@@ -383,17 +395,19 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
state[id] = site state[id] = site
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(state) _ = json.NewEncoder(w).Encode(state) //nolint:errcheck
}) })
} }
addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
addr := fmt.Sprintf(":%d", cfg.Port)
fmt.Printf("HTTP Server listening on %s\n", addr) fmt.Printf("HTTP Server listening on %s\n", addr)
if err := http.ListenAndServe(addr, mux); err != nil { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server failed: %v", err) log.Printf("HTTP server error: %v", err)
} }
}() }()
return srv
} }
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
@@ -415,5 +429,7 @@ func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine)
Title string Title string
Sites []models.Site Sites []models.Site
}{Title: title, Sites: sites} }{Title: title, Sites: sites}
statusTpl.Execute(w, data) if err := statusTpl.Execute(w, data); err != nil {
log.Printf("Failed to render status page: %v", err)
}
} }
+553
View File
@@ -0,0 +1,553 @@
package server
import (
"bytes"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net"
"net/http"
"sync"
"testing"
"time"
)
// --- Mock Store ---
type mockStore struct {
mu sync.Mutex
sites []models.Site
alerts []models.AlertConfig
nodes map[string]models.ProbeNode
importedData *models.Backup
registeredNodes []models.ProbeNode
maintWindows []models.MaintenanceWindow
}
func newMockStore() *mockStore {
return &mockStore{
nodes: make(map[string]models.ProbeNode),
}
}
func (m *mockStore) Init() error { return nil }
func (m *mockStore) GetSites() ([]models.Site, error) { return m.sites, nil }
func (m *mockStore) AddSite(models.Site) error { return nil }
func (m *mockStore) UpdateSite(models.Site) error { return nil }
func (m *mockStore) UpdateSitePaused(int, bool) error { return nil }
func (m *mockStore) DeleteSite(int) error { return nil }
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) { return m.alerts, nil }
func (m *mockStore) GetAlert(int) (models.AlertConfig, error) { return models.AlertConfig{}, nil }
func (m *mockStore) AddAlert(string, string, map[string]string) error { return nil }
func (m *mockStore) UpdateAlert(int, string, string, map[string]string) error { return nil }
func (m *mockStore) DeleteAlert(int) error { return nil }
func (m *mockStore) GetAllUsers() ([]models.User, error) { return nil, nil }
func (m *mockStore) AddUser(string, string, string) error { return nil }
func (m *mockStore) UpdateUser(int, string, string, string) error { return nil }
func (m *mockStore) DeleteUser(int) error { return nil }
func (m *mockStore) SaveCheck(int, int64, bool) error { return nil }
func (m *mockStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
return nil
}
func (m *mockStore) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
}
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
return nil, nil
}
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
func (m *mockStore) SetPreference(string, string) error { return nil }
func (m *mockStore) Close() error { return nil }
func (m *mockStore) ExportData() (models.Backup, error) {
return models.Backup{
Sites: m.sites,
Alerts: m.alerts,
}, nil
}
func (m *mockStore) ImportData(data models.Backup) error {
m.mu.Lock()
defer m.mu.Unlock()
m.importedData = &data
return nil
}
func (m *mockStore) RegisterNode(node models.ProbeNode) error {
m.mu.Lock()
defer m.mu.Unlock()
m.registeredNodes = append(m.registeredNodes, node)
m.nodes[node.ID] = node
return nil
}
func (m *mockStore) GetNode(id string) (models.ProbeNode, error) {
m.mu.Lock()
defer m.mu.Unlock()
if n, ok := m.nodes[id]; ok {
return n, nil
}
return models.ProbeNode{}, fmt.Errorf("not found")
}
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
return m.maintWindows, nil
}
// --- Helpers ---
func freePort() int {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
port := ln.Addr().(*net.TCPAddr).Port
ln.Close()
return port
}
type testServer struct {
baseURL string
srv *http.Server
store *mockStore
engine *monitor.Engine
}
func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServer {
t.Helper()
ms := newMockStore()
eng := monitor.NewEngine(ms)
port := freePort()
srv := Start(ServerConfig{
Port: port,
EnableStatus: enableStatus,
Title: "Test Status",
ClusterKey: clusterKey,
}, ms, eng)
ts := &testServer{
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
srv: srv,
store: ms,
engine: eng,
}
// Wait for server to be ready
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
resp, err := http.Get(ts.baseURL + "/api/health")
if err == nil {
resp.Body.Close()
break
}
time.Sleep(10 * time.Millisecond)
}
t.Cleanup(func() {
srv.Close()
})
return ts
}
func authReq(method, url, secret string, body []byte) (*http.Response, error) {
var req *http.Request
var err error
if body != nil {
req, err = http.NewRequest(method, url, bytes.NewReader(body))
} else {
req, err = http.NewRequest(method, url, nil)
}
if err != nil {
return nil, err
}
if secret != "" {
req.Header.Set("X-Upkeep-Secret", secret)
}
return http.DefaultClient.Do(req)
}
// --- Tests ---
func TestCheckSecret(t *testing.T) {
if !checkSecret("mykey", "mykey") {
t.Error("expected match")
}
if checkSecret("mykey", "wrong") {
t.Error("expected no match")
}
if checkSecret("", "key") {
t.Error("expected no match for empty got")
}
}
// --- Push Heartbeat ---
func TestPush_MissingToken(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := http.Get(ts.baseURL + "/api/push")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("expected 400, got %d", resp.StatusCode)
}
}
func TestPush_InvalidToken(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := http.Get(ts.baseURL + "/api/push?token=bad")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
}
// --- Health ---
func TestHealth_NoSecret(t *testing.T) {
ts := newTestServer(t, "", false)
resp, err := http.Get(ts.baseURL + "/api/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200 with no cluster key, got %d", resp.StatusCode)
}
}
func TestHealth_ValidSecret(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/health", "secret", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestHealth_WrongSecret(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/health", "wrong", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
// --- Backup Export ---
func TestExport_Unauthorized_NoKey(t *testing.T) {
ts := newTestServer(t, "", false)
resp, err := http.Get(ts.baseURL + "/api/backup/export")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401 when no cluster key configured, got %d", resp.StatusCode)
}
}
func TestExport_Unauthorized_WrongKey(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "wrong", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestExport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
ts.store.sites = []models.Site{{ID: 1, Name: "example", URL: "http://example.com"}}
resp, err := authReq("GET", ts.baseURL+"/api/backup/export", "secret", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var backup models.Backup
json.NewDecoder(resp.Body).Decode(&backup)
if len(backup.Sites) != 1 {
t.Errorf("expected 1 site, got %d", len(backup.Sites))
}
}
// --- Backup Import ---
func TestImport_MethodNotAllowed(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/backup/import", "secret", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 405 {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
}
func TestImport_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(models.Backup{})
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
func TestImport_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
backup := models.Backup{
Sites: []models.Site{{Name: "imported", URL: "http://example.com"}},
}
body, _ := json.Marshal(backup)
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
ts.store.mu.Lock()
defer ts.store.mu.Unlock()
if ts.store.importedData == nil {
t.Error("expected import data to be stored")
}
}
func TestImport_InvalidJSON(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", []byte("not json"))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("expected 400, got %d", resp.StatusCode)
}
}
// --- Probe Registration ---
func TestProbeRegister_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{
"id": "node-1", "name": "US East", "region": "us-east",
})
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
ts.store.mu.Lock()
defer ts.store.mu.Unlock()
if len(ts.store.registeredNodes) != 1 {
t.Errorf("expected 1 registered node, got %d", len(ts.store.registeredNodes))
}
if ts.store.registeredNodes[0].ID != "node-1" {
t.Errorf("expected node-1, got %s", ts.store.registeredNodes[0].ID)
}
}
func TestProbeRegister_MissingID(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{"name": "test"})
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("expected 400, got %d", resp.StatusCode)
}
}
func TestProbeRegister_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]string{"id": "node-1"})
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
// --- Probe Results ---
func TestProbeResults_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]any{
"node_id": "node-1",
"results": []map[string]any{
{"site_id": 1, "latency_ns": 5000000, "is_up": true},
},
})
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestProbeResults_MissingNodeID(t *testing.T) {
ts := newTestServer(t, "secret", false)
body, _ := json.Marshal(map[string]any{
"results": []map[string]any{},
})
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("expected 400, got %d", resp.StatusCode)
}
}
// --- Status Page ---
func TestStatusPage_Enabled(t *testing.T) {
ts := newTestServer(t, "secret", true)
resp, err := http.Get(ts.baseURL + "/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestStatusJSON_TokensStripped(t *testing.T) {
ts := newTestServer(t, "secret", true)
// Inject a site with a token into engine state
ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"})
// Need to inject directly since UpdateSiteConfig only updates existing
func() {
ts.engine.RecordHeartbeat("unused") // just to exercise, won't match
}()
resp, err := http.Get(ts.baseURL + "/status/json")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var state map[string]models.Site
json.NewDecoder(resp.Body).Decode(&state)
for _, site := range state {
if site.Token != "" {
t.Error("expected token stripped from status JSON response")
}
}
}
func TestStatusJSON_MaintenanceOverride(t *testing.T) {
ts := newTestServer(t, "secret", true)
ts.store.maintWindows = []models.MaintenanceWindow{
{ID: 1, MonitorID: 0, Type: "maintenance", StartTime: time.Now().Add(-1 * time.Hour)},
}
resp, err := http.Get(ts.baseURL + "/status/json")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
func TestStatusPage_Disabled(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := http.Get(ts.baseURL + "/status")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Errorf("expected 404 when status disabled, got %d", resp.StatusCode)
}
}
// --- Probe Assignments ---
func TestProbeAssignments_Success(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "secret", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var result map[string][]models.Site
json.NewDecoder(resp.Body).Decode(&result)
if _, ok := result["sites"]; !ok {
t.Error("expected 'sites' key in response")
}
}
func TestProbeAssignments_Unauthorized(t *testing.T) {
ts := newTestServer(t, "secret", false)
resp, err := authReq("GET", ts.baseURL+"/api/probe/assignments", "wrong", nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
}
+29 -8
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -67,6 +68,10 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
} }
} }
@@ -95,15 +100,31 @@ func (d *PostgresDialect) UpsertNodeSQL() string {
func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {} func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) { func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") if _, err := tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE"); err != nil {
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") log.Printf("import wipe error: %v", err)
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") }
tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE") if _, err := tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("TRUNCATE TABLE maintenance_windows RESTART IDENTITY CASCADE"); err != nil {
log.Printf("import wipe error: %v", err)
}
} }
func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") if _, err := tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))"); err != nil {
tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))") log.Printf("sequence reset error: %v", err)
tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))") }
tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))") if _, err := tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))"); err != nil {
log.Printf("sequence reset error: %v", err)
}
if _, err := tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))"); err != nil {
log.Printf("sequence reset error: %v", err)
}
if _, err := tx.Exec("SELECT setval('maintenance_windows_id_seq', (SELECT COALESCE(MAX(id), 1) FROM maintenance_windows))"); err != nil {
log.Printf("sequence reset error: %v", err)
}
} }
+33 -10
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"database/sql" "database/sql"
"log"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -67,6 +68,10 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
created_by TEXT DEFAULT '', created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
} }
} }
@@ -94,21 +99,39 @@ func (d *SQLiteDialect) UpsertNodeSQL() string {
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
var count int var count int
db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) _ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
if count == 0 { if count == 0 {
db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table) if _, err := db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table); err != nil {
log.Printf("sequence cleanup error: %v", err)
}
} }
} }
func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) { func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("DELETE FROM sites") if _, err := tx.Exec("DELETE FROM sites"); err != nil {
tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") log.Printf("import wipe error: %v", err)
tx.Exec("DELETE FROM alerts") }
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'"); err != nil {
tx.Exec("DELETE FROM users") log.Printf("import wipe error: %v", err)
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") }
tx.Exec("DELETE FROM maintenance_windows") if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
tx.Exec("DELETE FROM sqlite_sequence WHERE name='maintenance_windows'") log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM users"); err != nil {
log.Printf("import wipe error: %v", err)
}
if _, err := tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'"); err != nil {
log.Printf("import wipe error: %v", err)
}
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)
}
} }
func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {} func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
+52 -13
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"log"
"time" "time"
) )
@@ -29,12 +30,16 @@ func (s *SQLStore) q(query string) string {
return rewritePlaceholders(query, s.dollar) return rewritePlaceholders(query, s.dollar)
} }
func generateToken() string { func generateToken() (string, error) {
b := make([]byte, 16) b := make([]byte, 16)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error()) return "", fmt.Errorf("crypto/rand failed: %w", err)
} }
return hex.EncodeToString(b) return hex.EncodeToString(b), nil
}
func (s *SQLStore) Close() error {
return s.db.Close()
} }
func (s *SQLStore) Init() error { func (s *SQLStore) Init() error {
@@ -44,14 +49,16 @@ func (s *SQLStore) Init() error {
} }
} }
for _, m := range s.dialect.MigrationsSQL() { for _, m := range s.dialect.MigrationsSQL() {
s.db.Exec(m) if _, err := s.db.Exec(m); err != nil {
log.Printf("migration error: %v", err)
}
} }
return nil return nil
} }
func (s *SQLStore) GetSites() ([]models.Site, error) { func (s *SQLStore) GetSites() ([]models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( 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,
) )
@@ -77,7 +84,11 @@ func (s *SQLStore) GetSites() ([]models.Site, error) {
func (s *SQLStore) AddSite(site models.Site) error { func (s *SQLStore) AddSite(site models.Site) error {
token := "" token := ""
if site.Type == "push" { if site.Type == "push" {
token = generateToken() var err error
token, err = generateToken()
if err != nil {
return fmt.Errorf("generate push token: %w", err)
}
} }
_, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"), _, err := s.db.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,
@@ -87,9 +98,13 @@ func (s *SQLStore) AddSite(site models.Site) error {
func (s *SQLStore) UpdateSite(site models.Site) error { func (s *SQLStore) UpdateSite(site models.Site) error {
var existingToken string var existingToken string
s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) _ = s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken) //nolint:errcheck
if site.Type == "push" && existingToken == "" { if site.Type == "push" && existingToken == "" {
existingToken = generateToken() var err error
existingToken, err = generateToken()
if err != nil {
return fmt.Errorf("generate push token: %w", err)
}
} }
_, err := s.db.Exec(s.q("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=?, hostname=?, port=?, timeout=?, method=?, description=?, parent_id=?, accepted_codes=?, dns_resolve_type=?, dns_server=?, ignore_tls=?, paused=?, regions=? WHERE id=?"), _, err := s.db.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,
@@ -113,7 +128,7 @@ func (s *SQLStore) DeleteSite(id int) error {
func (s *SQLStore) GetSiteByName(name string) (models.Site, error) { func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse() bf := s.dialect.BoolFalse()
query := fmt.Sprintf( 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("?"),
) )
@@ -132,7 +147,9 @@ func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
if err != nil { if err != nil {
return a, err return a, err
} }
json.Unmarshal([]byte(settingsJSON), &a.Settings) if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
}
return a, nil return a, nil
} }
@@ -171,7 +188,9 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil { if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil {
return alerts, err return alerts, err
} }
json.Unmarshal([]byte(settingsJSON), &a.Settings) if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err)
}
alerts = append(alerts, a) alerts = append(alerts, a)
} }
return alerts, rows.Err() return alerts, rows.Err()
@@ -184,7 +203,9 @@ func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
if err != nil { if err != nil {
return a, err return a, err
} }
json.Unmarshal([]byte(settingsJSON), &a.Settings) if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
}
return a, nil return a, nil
} }
@@ -441,6 +462,24 @@ func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
return count > 0, nil return count > 0, nil
} }
func (s *SQLStore) GetPreference(key string) (string, error) {
var value string
err := s.db.QueryRow(s.q("SELECT value FROM preferences WHERE key = ?"), key).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
func (s *SQLStore) SetPreference(key, value string) error {
if s.dollar {
_, err := s.db.Exec(s.q("INSERT INTO preferences (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = ?"), key, value, value)
return err
}
_, err := s.db.Exec("INSERT OR REPLACE INTO preferences (key, value) VALUES (?, ?)", key, value)
return err
}
func (s *SQLStore) ExportData() (models.Backup, error) { func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites() sites, err := s.GetSites()
if err != nil { if err != nil {
@@ -466,7 +505,7 @@ func (s *SQLStore) ImportData(data models.Backup) error {
if err != nil { if err != nil {
return err return err
} }
defer tx.Rollback() defer tx.Rollback() //nolint:errcheck
s.dialect.ImportWipe(tx) s.dialect.ImportWipe(tx)
+7
View File
@@ -57,7 +57,14 @@ type Store interface {
DeleteMaintenanceWindow(id int) error DeleteMaintenanceWindow(id int) error
IsMonitorInMaintenance(monitorID int) (bool, error) IsMonitorInMaintenance(monitorID int) (bool, error)
// Preferences
GetPreference(key string) (string, error)
SetPreference(key, value string) error
// Backup & Restore // Backup & Restore
ExportData() (models.Backup, error) ExportData() (models.Backup, error)
ImportData(data models.Backup) error ImportData(data models.Backup) error
// Lifecycle
Close() error
} }
-25
View File
@@ -2,8 +2,6 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models"
"strings"
"time" "time"
) )
@@ -71,26 +69,3 @@ func fmtNodeLastSeen(t time.Time) string {
} }
return fmt.Sprintf("%dh ago", int(ago.Hours())) return fmt.Sprintf("%dh ago", int(ago.Hours()))
} }
func fmtProbeRegions(site models.Site, probeResults map[string]probeStatus) string {
if len(probeResults) == 0 {
return subtleStyle.Render("—")
}
var parts []string
for region, status := range probeResults {
short := region
if len(short) > 6 {
short = short[:6]
}
if status.isUp {
parts = append(parts, specialStyle.Render(short+":UP"))
} else {
parts = append(parts, dangerStyle.Render(short+":DN"))
}
}
return strings.Join(parts, " ")
}
type probeStatus struct {
isUp bool
}
+139 -12
View File
@@ -29,9 +29,9 @@ func typeIcon(siteType string, collapsed bool) string {
return "◆" return "◆"
case "group": case "group":
if collapsed { if collapsed {
return "" return ""
} }
return "" return ""
default: default:
return "·" return "·"
} }
@@ -132,6 +132,93 @@ func heartbeatSparkline(statuses []bool, width int) string {
return sb.String() return sb.String()
} }
func (m Model) groupSparkline(groupID int, width int) string {
allSites := m.engine.GetAllSites()
var childStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
childStatuses = append(childStatuses, hist.Statuses)
}
}
}
if len(childStatuses) == 0 {
return subtleStyle.Render(strings.Repeat("·", width))
}
maxLen := 0
for _, s := range childStatuses {
if len(s) > maxLen {
maxLen = len(s)
}
}
if maxLen > width {
maxLen = width
}
aggregated := make([]bool, maxLen)
for i := 0; i < maxLen; i++ {
allUp := true
for _, statuses := range childStatuses {
idx := len(statuses) - maxLen + i
if idx >= 0 && !statuses[idx] {
allUp = false
break
}
}
aggregated[i] = allUp
}
var sb strings.Builder
if remaining := width - len(aggregated); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
for _, up := range aggregated {
if up {
sb.WriteString(specialStyle.Render("●"))
} else {
sb.WriteString(dangerStyle.Render("●"))
}
}
return sb.String()
}
func (m Model) groupUptime(groupID int) string {
allSites := m.engine.GetAllSites()
var allStatuses [][]bool
for _, s := range allSites {
if s.ParentID == groupID && !s.Paused && !m.isMonitorInMaintenance(s.ID) {
hist, _ := m.engine.GetHistory(s.ID)
if len(hist.Statuses) > 0 {
allStatuses = append(allStatuses, hist.Statuses)
}
}
}
if len(allStatuses) == 0 {
return subtleStyle.Render("—")
}
total, up := 0, 0
for _, statuses := range allStatuses {
for _, s := range statuses {
total++
if s {
up++
}
}
}
return fmtUptime(func() []bool {
out := make([]bool, total)
idx := 0
for _, statuses := range allStatuses {
copy(out[idx:], statuses)
idx += len(statuses)
}
return out
}())
}
func fmtLatency(d time.Duration) string { func fmtLatency(d time.Duration) string {
ms := d.Milliseconds() ms := d.Milliseconds()
if ms == 0 { if ms == 0 {
@@ -214,10 +301,10 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
if inMaint { if inMaint {
return maintStyle.Render("MAINT") return maintStyle.Render("MAINT")
} }
switch { switch status {
case status == "DOWN" || status == "SSL EXP": case "DOWN", "SSL EXP":
return dangerStyle.Render(status) return dangerStyle.Render(status)
case status == "PENDING": case "PENDING":
return subtleStyle.Render(status) return subtleStyle.Render(status)
default: default:
return specialStyle.Render(status) return specialStyle.Render(status)
@@ -227,7 +314,7 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
func (m Model) dynamicWidths() (nameW, sparkW int) { func (m Model) dynamicWidths() (nameW, sparkW int) {
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
overhead := 30 // cell padding + borders overhead := 30 // cell padding + borders
avail := m.termWidth - 6 - fixed - overhead avail := m.termWidth - chromePadH - 2 - fixed - overhead
if avail < 30 { if avail < 30 {
avail = 30 avail = 30
} }
@@ -285,8 +372,8 @@ func (m Model) viewSitesTab() string {
"group", "group",
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
subtleStyle.Render("—"), subtleStyle.Render("—"),
subtleStyle.Render("—"), m.groupUptime(site.ID),
subtleStyle.Render(strings.Repeat("·", sparkWidth)), m.groupSparkline(site.ID, sparkWidth),
subtleStyle.Render("-"), subtleStyle.Render("-"),
subtleStyle.Render("—"), subtleStyle.Render("—"),
}) })
@@ -619,11 +706,22 @@ func (m Model) viewDetailPanel() string {
var b strings.Builder var b strings.Builder
title := titleStyle.Render(fmt.Sprintf(" %s", site.Name)) var breadcrumb string
b.WriteString(title + "\n\n") if site.ParentID > 0 {
for _, s := range m.sites {
if s.ID == site.ParentID {
breadcrumb = subtleStyle.Render(" Sites > "+s.Name+" > ") + titleStyle.Render(site.Name)
break
}
}
}
if breadcrumb == "" {
breadcrumb = subtleStyle.Render(" Sites > ") + titleStyle.Render(site.Name)
}
b.WriteString(breadcrumb + "\n\n")
row := func(label, value string) { row := func(label, value string) {
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value)) fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
} }
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))) row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
@@ -682,7 +780,7 @@ func (m Model) viewDetailPanel() string {
} }
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)
b.WriteString(fmt.Sprintf(" %-14s %s %dms %s ago\n", nodeID, status, latency, ago)) fmt.Fprintf(&b, " %-14s %s %dms %s ago\n", nodeID, status, latency, ago)
} }
} }
@@ -690,8 +788,37 @@ func (m Model) viewDetailPanel() string {
const sparkWidth = 40 const sparkWidth = 40
if site.Type == "push" { if site.Type == "push" {
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
if len(hist.Statuses) > 0 {
up := 0
for _, s := range hist.Statuses {
if s {
up++
}
}
fmt.Fprintf(&b, "\n %s %d/%d checks up",
subtleStyle.Render("Heartbeats"),
up, len(hist.Statuses))
}
} else { } else {
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth)) b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
if len(hist.Latencies) > 0 {
minL, maxL := hist.Latencies[0], hist.Latencies[0]
var total time.Duration
for _, l := range hist.Latencies {
total += l
if l < minL {
minL = l
}
if l > maxL {
maxL = l
}
}
avg := total / time.Duration(len(hist.Latencies))
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
subtleStyle.Render("Min"), minL.Milliseconds(),
subtleStyle.Render("Avg"), avg.Milliseconds(),
subtleStyle.Render("Max"), maxL.Milliseconds())
}
} }
b.WriteString("\n\n") b.WriteString("\n\n")
+17 -5
View File
@@ -21,6 +21,10 @@ var (
tableBorderStyle = lipgloss.NewStyle(). tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444")) Foreground(lipgloss.Color("#444"))
tableZebraStyle = lipgloss.NewStyle().
Padding(0, 1).
Background(lipgloss.Color("#1a1a2e"))
) )
type StyleOverride func(row, col int) *lipgloss.Style type StyleOverride func(row, col int) *lipgloss.Style
@@ -38,7 +42,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
selectedVisual := m.cursor - m.tableOffset selectedVisual := m.cursor - m.tableOffset
rows := buildRows(m.tableOffset, end) rows := buildRows(m.tableOffset, end)
tableWidth := m.termWidth - 6 tableWidth := m.termWidth - chromePadH - 2
if tableWidth < 40 { if tableWidth < 40 {
tableWidth = 40 tableWidth = 40
} }
@@ -53,16 +57,24 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
if row == table.HeaderRow { if row == table.HeaderRow {
return tableHeaderStyle return tableHeaderStyle
} }
isSelected := row == selectedVisual
if styleOverride != nil { if styleOverride != nil {
if s := styleOverride(row, col); s != nil { if s := styleOverride(row, col); s != nil {
if col < len(colWidths) && colWidths[col] > 0 { style := *s
return s.Width(colWidths[col]) if isSelected {
style = tableSelectedStyle.Foreground(s.GetForeground())
} }
return *s if col < len(colWidths) && colWidths[col] > 0 {
style = style.Width(colWidths[col])
}
return style
} }
} }
base := tableCellStyle base := tableCellStyle
if row == selectedVisual { if row%2 == 1 {
base = tableZebraStyle
}
if isSelected {
base = tableSelectedStyle base = tableSelectedStyle
} }
if col < len(colWidths) && colWidths[col] > 0 { if col < len(colWidths) && colWidths[col] > 0 {
+75 -27
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
@@ -31,6 +32,16 @@ var (
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
chromePadV = 2 // outer Padding(1,2): 1 top + 1 bottom
chromePadH = 4 // outer Padding(1,2): 2 left + 2 right
chromeHeader = 1 // tab bar line
chromeGaps = 2 // "\n" separators: before content + before footer
chromeFooter = 2 // footer: "\n" prefix + text line
chromeTable = 3 // renderTable "\n" prefix + top border + header + bottom border (lipgloss collapses two into three rendered lines)
chromeBase = chromePadV + chromeHeader + chromeGaps + chromeFooter + chromeTable
)
type sessionState int type sessionState int
const ( const (
@@ -95,6 +106,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
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)
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -104,10 +116,37 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
engine: eng, engine: eng,
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: make(map[int]bool), collapsed: collapsed,
} }
} }
func loadCollapsed(s store.Store) map[int]bool {
m := make(map[int]bool)
raw, err := s.GetPreference("collapsed_groups")
if err != nil || raw == "" {
return m
}
var ids []int
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return m
}
for _, id := range ids {
m[id] = true
}
return m
}
func saveCollapsed(s store.Store, collapsed map[int]bool) {
var ids []int
for id, v := range collapsed {
if v {
ids = append(ids, id)
}
}
data, _ := json.Marshal(ids)
_ = s.SetPreference("collapsed_groups", string(data))
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })) return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }))
} }
@@ -198,17 +237,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.termWidth = msg.Width m.termWidth = msg.Width
m.termHeight = msg.Height m.termHeight = msg.Height
// Chrome: 1 top pad + 1 tabs + 2 newlines + 3 table borders + 1 table header + 1 footer + 1 bottom pad = 10 chrome := chromeBase
chrome := 10 if m.filterMode || m.filterText != "" {
if m.filterText != "" {
chrome++ chrome++
} }
m.maxTableRows = msg.Height - chrome m.maxTableRows = msg.Height - chrome
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
} }
m.logViewport.Width = msg.Width - 4 m.logViewport.Width = msg.Width - chromePadH
m.logViewport.Height = msg.Height - 8 m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
return m, tea.ClearScreen return m, tea.ClearScreen
case time.Time: case time.Time:
@@ -226,20 +264,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
if m.state == stateLogs { if m.state == stateLogs {
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
m.logViewport.LineUp(3) m.logViewport.ScrollUp(3)
} else { } else {
m.logViewport.LineDown(3) m.logViewport.ScrollDown(3)
} }
return m, nil return m, nil
} }
listLen := len(m.sites) listLen := len(m.sites)
if m.currentTab == 1 { switch m.currentTab {
case 1:
listLen = len(m.alerts) listLen = len(m.alerts)
} else if m.currentTab == 3 { case 3:
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { case 4:
listLen = len(m.maintenanceWindows) listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 { case 5:
listLen = len(m.users) listLen = len(m.users)
} }
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
@@ -326,7 +365,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "up", "k": case "up", "k":
if m.state == stateLogs { if m.state == stateLogs {
m.logViewport.LineUp(1) m.logViewport.ScrollUp(1)
} else if m.cursor > 0 { } else if m.cursor > 0 {
m.cursor-- m.cursor--
if m.cursor < m.tableOffset { if m.cursor < m.tableOffset {
@@ -335,7 +374,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case "down", "j": case "down", "j":
if m.state == stateLogs { if m.state == stateLogs {
m.logViewport.LineDown(1) m.logViewport.ScrollDown(1)
} else { } else {
max := len(m.sites) - 1 max := len(m.sites) - 1
if m.currentTab == 1 { if m.currentTab == 1 {
@@ -392,6 +431,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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]
saveCollapsed(m.store, m.collapsed)
m.refreshData() m.refreshData()
} }
case "p": case "p":
@@ -606,13 +646,14 @@ func (m *Model) refreshData() {
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n")) m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
listLen := len(m.sites) listLen := len(m.sites)
if m.currentTab == 1 { switch m.currentTab {
case 1:
listLen = len(m.alerts) listLen = len(m.alerts)
} else if m.currentTab == 3 { case 3:
listLen = len(m.nodes) listLen = len(m.nodes)
} else if m.currentTab == 4 { case 4:
listLen = len(m.maintenanceWindows) listLen = len(m.maintenanceWindows)
} else if m.currentTab == 5 { case 5:
listLen = len(m.users) listLen = len(m.users)
} }
if listLen > 0 && m.cursor >= listLen { if listLen > 0 && m.cursor >= listLen {
@@ -670,11 +711,12 @@ func (m Model) View() string {
switch m.state { switch m.state {
case stateConfirmDelete: case stateConfirmDelete:
kind := "monitor" kind := "monitor"
if m.deleteTab == 1 { switch m.deleteTab {
case 1:
kind = "alert" kind = "alert"
} else if m.deleteTab == 4 { case 4:
kind = "maintenance window" kind = "maintenance window"
} else if m.deleteTab == 5 { case 5:
kind = "user" kind = "user"
} }
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName)) msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
@@ -725,8 +767,14 @@ func (m Model) View() string {
} }
func (m Model) viewDashboard() string { func (m Model) viewDashboard() string {
allSites := m.engine.GetAllSites()
totalMonitors := 0
downCount := 0 downCount := 0
for _, s := range m.sites { for _, s := range allSites {
if s.Type == "group" {
continue
}
totalMonitors++
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") { if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
downCount++ downCount++
} }
@@ -741,8 +789,8 @@ func (m Model) viewDashboard() string {
var sitesLabel string var sitesLabel string
if downCount > 0 { if downCount > 0 {
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount) sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
} else if len(m.sites) > 0 { } else if totalMonitors > 0 {
sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites)) sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
} else { } else {
sitesLabel = "Sites" sitesLabel = "Sites"
} }
@@ -806,12 +854,12 @@ func (m Model) viewDashboard() string {
} }
} }
upCount := len(m.sites) - downCount upCount := totalMonitors - downCount
var upStr string var upStr string
if downCount > 0 { if downCount > 0 {
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
} else { } else {
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites))) upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
} }
statusParts := []string{upStr} statusParts := []string{upStr}
if len(m.nodes) > 0 { if len(m.nodes) > 0 {