Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fa2b1d98c | |||
| 09e1bec9a3 | |||
| deb7d017af | |||
| 1e0ae22447 | |||
| 611f26846c | |||
| 8f9210b451 | |||
| cc8d76fdbc | |||
| 26268bb6ef | |||
| 5915e0ebe3 | |||
| 6d7ecc46eb | |||
| fb3f96f608 | |||
| 359cff7292 | |||
| da61ce0f88 | |||
| 7398f520f0 | |||
| c6d120d7a4 | |||
| 94296e8286 | |||
| 4b5495fb49 | |||
| 4891843c94 | |||
| 93c5b638cf | |||
| 8e6d97710b | |||
| ae141c62ba | |||
| ba53845193 | |||
| fb11e9ba85 | |||
| e84b64f8ed | |||
| 88e4f0ed69 | |||
| 8e948bf187 | |||
| dc672d6cba | |||
| a89584dac1 | |||
| d437f54797 | |||
| b146f34d19 | |||
| 5de834465f | |||
| ea401136a9 | |||
| 5a9b19b3e8 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: upkeep
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "23234:23234"
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- UPKEEP_DB_TYPE=sqlite
|
||||||
|
- UPKEEP_DB_DSN=/data/upkeep.db
|
||||||
|
- UPKEEP_HTTP_PORT=8080
|
||||||
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
|
- UPKEEP_STATUS_TITLE=System Status
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
+17
-6
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
|
|||||||
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
|
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
|
||||||
|
for _, s := range sites {
|
||||||
|
val := 0
|
||||||
|
if eng.GetDisplayStatus(s) == "MAINT" {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val))
|
||||||
|
}
|
||||||
|
|
||||||
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if !s.HasSSL || s.CertExpiry.IsZero() {
|
if !s.HasSSL || s.CertExpiry.IsZero() {
|
||||||
@@ -88,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ func (m *mockStore) UpdateNodeLastSeen(string) error { return n
|
|||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
func (m *mockStore) SaveLog(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) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
|
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||||
|
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||||
|
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||||
|
func (m *mockStore) Close() error { return nil }
|
||||||
|
|
||||||
func TestMetricsHandler(t *testing.T) {
|
func TestMetricsHandler(t *testing.T) {
|
||||||
ms := &mockStore{
|
ms := &mockStore{
|
||||||
|
|||||||
@@ -67,8 +67,21 @@ type ProbeNode struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Backup struct {
|
type MaintenanceWindow struct {
|
||||||
Sites []Site `json:"sites"`
|
ID int
|
||||||
Alerts []AlertConfig `json:"alerts"`
|
MonitorID int
|
||||||
Users []User `json:"users"`
|
Title string
|
||||||
|
Description string
|
||||||
|
Type string // "maintenance" or "incident"
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time // zero = ongoing
|
||||||
|
CreatedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Backup struct {
|
||||||
|
Sites []Site `json:"sites"`
|
||||||
|
Alerts []AlertConfig `json:"alerts"`
|
||||||
|
Users []User `json:"users"`
|
||||||
|
MaintenanceWindows []MaintenanceWindow `json:"maintenance_windows,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-12
@@ -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
|
||||||
}
|
}
|
||||||
@@ -385,10 +395,16 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
newState.FailureCount = site.MaxRetries + 1
|
newState.FailureCount = site.MaxRetries + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inMaint := e.isInMaintenance(site.ID)
|
||||||
|
|
||||||
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
||||||
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
|
daysLeft := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||||
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
|
if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" {
|
||||||
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
if !inMaint {
|
||||||
|
e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", site.Name))
|
||||||
|
}
|
||||||
newState.SentSSLWarning = true
|
newState.SentSSLWarning = true
|
||||||
} else if daysLeft > site.ExpiryThreshold {
|
} else if daysLeft > site.ExpiryThreshold {
|
||||||
newState.SentSSLWarning = false
|
newState.SentSSLWarning = false
|
||||||
@@ -405,20 +421,29 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
|
|
||||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||||
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
||||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
if inMaint {
|
||||||
if site.Type == "push" {
|
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
||||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
} else {
|
||||||
|
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
||||||
|
if site.Type == "push" {
|
||||||
|
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
||||||
|
}
|
||||||
|
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
||||||
}
|
}
|
||||||
e.triggerAlert(site.AlertID, "🚨 ALERT", msg)
|
|
||||||
}
|
}
|
||||||
if isBroken(site.Status) && newState.Status == "UP" {
|
if isBroken(site.Status) && newState.Status == "UP" {
|
||||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
if !inMaint {
|
||||||
|
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
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)
|
||||||
@@ -426,12 +451,31 @@ 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))
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||||
|
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return inMaint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||||
|
if site.Paused {
|
||||||
|
return "PAUSED"
|
||||||
|
}
|
||||||
|
if e.isInMaintenance(site.ID) {
|
||||||
|
return "MAINT"
|
||||||
|
}
|
||||||
|
return site.Status
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) checkGroup(site models.Site) {
|
func (e *Engine) checkGroup(site models.Site) {
|
||||||
e.mu.RLock()
|
e.mu.RLock()
|
||||||
status := "UP"
|
status := "UP"
|
||||||
@@ -445,7 +489,7 @@ func (e *Engine) checkGroup(site models.Site) {
|
|||||||
if !child.Paused {
|
if !child.Paused {
|
||||||
allPaused = false
|
allPaused = false
|
||||||
}
|
}
|
||||||
if child.Paused {
|
if child.Paused || e.isInMaintenance(child.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if child.Status == "DOWN" || child.Status == "SSL EXP" {
|
if child.Status == "DOWN" || child.Status == "SSL EXP" {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+82
-47
@@ -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>
|
||||||
@@ -35,6 +41,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
.PENDING { background: #e0af68; color: #1a1b26; }
|
.PENDING { background: #e0af68; color: #1a1b26; }
|
||||||
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
||||||
.PAUSED { background: #565f89; color: #c0caf5; }
|
.PAUSED { background: #565f89; color: #c0caf5; }
|
||||||
|
.MAINT { background: #bb9af7; color: #1a1b26; }
|
||||||
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
|
.summary { display: flex; justify-content: center; gap: 16px; margin-bottom: 24px; font-size: 0.95em; font-weight: 600; }
|
||||||
.summary span { padding: 4px 12px; border-radius: 6px; }
|
.summary span { padding: 4px 12px; border-radius: 6px; }
|
||||||
.summary .s-up { color: #9ece6a; }
|
.summary .s-up { color: #9ece6a; }
|
||||||
@@ -68,15 +75,17 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(sites) {
|
function renderSummary(sites) {
|
||||||
var up = 0, down = 0, paused = 0, total = sites.length;
|
var up = 0, down = 0, paused = 0, maint = 0, total = sites.length;
|
||||||
for (var i = 0; i < sites.length; i++) {
|
for (var i = 0; i < sites.length; i++) {
|
||||||
if (sites[i].Paused) { paused++; continue; }
|
if (sites[i].Paused) { paused++; continue; }
|
||||||
|
if (sites[i].Status === 'MAINT') { maint++; continue; }
|
||||||
if (sites[i].Status === 'UP') up++;
|
if (sites[i].Status === 'UP') up++;
|
||||||
else if (sites[i].Status === 'DOWN') down++;
|
else if (sites[i].Status === 'DOWN') down++;
|
||||||
}
|
}
|
||||||
var el = document.getElementById('summary');
|
var el = document.getElementById('summary');
|
||||||
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
|
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
|
||||||
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
|
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
|
||||||
|
if (maint > 0) parts.push('<span style="color:#bb9af7">' + maint + ' MAINT</span>');
|
||||||
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
|
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
|
||||||
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
|
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
|
||||||
}
|
}
|
||||||
@@ -110,7 +119,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
renderSummary(sites);
|
renderSummary(sites);
|
||||||
for (var i = 0; i < sites.length; i++) {
|
for (var i = 0; i < sites.length; i++) {
|
||||||
var s = sites[i];
|
var s = sites[i];
|
||||||
var st = s.Paused ? 'PAUSED' : s.Status;
|
var st = s.Status === 'MAINT' ? 'MAINT' : s.Paused ? 'PAUSED' : s.Status;
|
||||||
var cls = cssClass(st);
|
var cls = cssClass(st);
|
||||||
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
|
var meta = esc(s.Type) + ' | ' + (s.Type === 'http' ? esc(s.URL) : 'Heartbeat Monitor');
|
||||||
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
var lc = s.LastCheck ? new Date(s.LastCheck).toLocaleTimeString('en-GB', {hour12: false}) : '—';
|
||||||
@@ -150,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.")
|
||||||
}
|
}
|
||||||
@@ -160,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"`
|
||||||
@@ -261,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")
|
||||||
@@ -312,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 {
|
||||||
@@ -334,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 {
|
||||||
@@ -347,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
|
||||||
@@ -359,22 +374,40 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
|
|||||||
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
|
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
|
||||||
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
state := eng.GetLiveState()
|
state := eng.GetLiveState()
|
||||||
|
activeWindows, _ := s.GetActiveMaintenanceWindows()
|
||||||
|
maintSet := make(map[int]bool)
|
||||||
|
allInMaint := false
|
||||||
|
for _, mw := range activeWindows {
|
||||||
|
if mw.Type != "maintenance" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mw.MonitorID == 0 {
|
||||||
|
allInMaint = true
|
||||||
|
} else {
|
||||||
|
maintSet[mw.MonitorID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
for id, site := range state {
|
for id, site := range state {
|
||||||
site.Token = ""
|
site.Token = ""
|
||||||
|
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
|
||||||
|
site.Status = "MAINT"
|
||||||
|
}
|
||||||
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) {
|
||||||
@@ -396,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,21 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
|||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT NOW()
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
monitor_id INTEGER DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT DEFAULT 'maintenance',
|
||||||
|
start_time TIMESTAMP NOT NULL,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS preferences (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,13 +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")
|
}
|
||||||
|
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))")
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,21 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
|||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
monitor_id INTEGER DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT DEFAULT 'maintenance',
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME,
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS preferences (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,19 +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'")
|
}
|
||||||
|
if _, err := tx.Exec("DELETE FROM alerts"); err != nil {
|
||||||
|
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) {}
|
||||||
|
|||||||
+149
-14
@@ -7,6 +7,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go-upkeep/internal/models"
|
"go-upkeep/internal/models"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SQLStore struct {
|
type SQLStore struct {
|
||||||
@@ -28,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 {
|
||||||
@@ -43,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,
|
||||||
)
|
)
|
||||||
@@ -76,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,
|
||||||
@@ -86,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,
|
||||||
@@ -112,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("?"),
|
||||||
)
|
)
|
||||||
@@ -131,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,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()
|
||||||
@@ -183,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +378,108 @@ func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, erro
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
||||||
|
var mw models.MaintenanceWindow
|
||||||
|
var endTime sql.NullTime
|
||||||
|
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
||||||
|
return mw, err
|
||||||
|
}
|
||||||
|
if endTime.Valid {
|
||||||
|
mw.EndTime = endTime.Time
|
||||||
|
}
|
||||||
|
return mw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error) {
|
||||||
|
rows, err := s.db.Query(s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return windows, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) AddMaintenanceWindow(mw models.MaintenanceWindow) error {
|
||||||
|
if mw.StartTime.IsZero() {
|
||||||
|
mw.StartTime = time.Now()
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) EndMaintenanceWindow(id int) error {
|
||||||
|
_, err := s.db.Exec(s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) DeleteMaintenanceWindow(id int) error {
|
||||||
|
_, err := s.db.Exec(s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) IsMonitorInMaintenance(monitorID int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM maintenance_windows
|
||||||
|
WHERE type = 'maintenance'
|
||||||
|
AND start_time <= CURRENT_TIMESTAMP
|
||||||
|
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
||||||
|
AND (monitor_id = 0 OR monitor_id = ?
|
||||||
|
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
||||||
|
monitorID, monitorID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetPreference(key string) (string, error) {
|
||||||
|
var value string
|
||||||
|
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 {
|
||||||
@@ -369,7 +493,11 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Backup{}, err
|
return models.Backup{}, err
|
||||||
}
|
}
|
||||||
return models.Backup{Sites: sites, Alerts: alerts, Users: users}, nil
|
windows, err := s.GetAllMaintenanceWindows(1000)
|
||||||
|
if err != nil {
|
||||||
|
return models.Backup{}, err
|
||||||
|
}
|
||||||
|
return models.Backup{Sites: sites, Alerts: alerts, Users: users, MaintenanceWindows: windows}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) ImportData(data models.Backup) error {
|
func (s *SQLStore) ImportData(data models.Backup) error {
|
||||||
@@ -377,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)
|
||||||
|
|
||||||
@@ -403,6 +531,13 @@ func (s *SQLStore) ImportData(data models.Backup) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, mw := range data.MaintenanceWindows {
|
||||||
|
if _, err := tx.Exec(s.q("INSERT INTO maintenance_windows (id, monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
|
||||||
|
mw.ID, mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.dialect.ImportResetSequences(tx)
|
s.dialect.ImportResetSequences(tx)
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|||||||
@@ -49,7 +49,22 @@ type Store interface {
|
|||||||
SaveLog(message string) error
|
SaveLog(message string) error
|
||||||
LoadLogs(limit int) ([]string, error)
|
LoadLogs(limit int) ([]string, error)
|
||||||
|
|
||||||
|
// Maintenance Windows
|
||||||
|
GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error)
|
||||||
|
GetAllMaintenanceWindows(limit int) ([]models.MaintenanceWindow, error)
|
||||||
|
AddMaintenanceWindow(mw models.MaintenanceWindow) error
|
||||||
|
EndMaintenanceWindow(id int) error
|
||||||
|
DeleteMaintenanceWindow(id int) error
|
||||||
|
IsMonitorInMaintenance(monitorID int) (bool, error)
|
||||||
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))
|
||||||
|
|
||||||
|
type maintFormData struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Type string
|
||||||
|
MonitorID string
|
||||||
|
Duration string
|
||||||
|
CustomHours string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtMaintStatus(mw models.MaintenanceWindow) string {
|
||||||
|
now := time.Now()
|
||||||
|
if mw.StartTime.After(now) {
|
||||||
|
return warnStyle.Render("SCHEDULED")
|
||||||
|
}
|
||||||
|
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||||
|
return subtleStyle.Render("ENDED")
|
||||||
|
}
|
||||||
|
return specialStyle.Render("ACTIVE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtMaintType(t string) string {
|
||||||
|
if t == "incident" {
|
||||||
|
return dangerStyle.Render("incident")
|
||||||
|
}
|
||||||
|
return maintStyle.Render("maintenance")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtMaintMonitor(monitorID int, sites []models.Site) string {
|
||||||
|
if monitorID == 0 {
|
||||||
|
return "All"
|
||||||
|
}
|
||||||
|
for _, s := range sites {
|
||||||
|
if s.ID == monitorID {
|
||||||
|
return limitStr(s.Name, 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("#%d", monitorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtMaintTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return subtleStyle.Render("—")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
|
return t.Format("15:04")
|
||||||
|
}
|
||||||
|
return t.Format("15:04 Jan 02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.Type != "maintenance" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if mw.StartTime.After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mw.MonitorID == 0 || mw.MonitorID == monitorID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, s := range m.sites {
|
||||||
|
if s.ID == monitorID && s.ParentID > 0 && mw.MonitorID == s.ParentID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) viewMaintTab() string {
|
||||||
|
if len(m.maintenanceWindows) == 0 {
|
||||||
|
return "\n No maintenance windows or incidents. Press [n] to create one."
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.renderTable(
|
||||||
|
[]string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"},
|
||||||
|
len(m.maintenanceWindows),
|
||||||
|
func(start, end int) [][]string {
|
||||||
|
var rows [][]string
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
mw := m.maintenanceWindows[i]
|
||||||
|
rows = append(rows, []string{
|
||||||
|
strconv.Itoa(i + 1),
|
||||||
|
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, 24)),
|
||||||
|
fmtMaintType(mw.Type),
|
||||||
|
fmtMaintMonitor(mw.MonitorID, allSites),
|
||||||
|
fmtMaintStatus(mw),
|
||||||
|
fmtMaintTime(mw.StartTime),
|
||||||
|
fmtMaintTime(mw.EndTime),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
[]int{6, 0, 14, 20, 12, 16, 16},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||||
|
m.maintFormData = &maintFormData{
|
||||||
|
Type: "maintenance",
|
||||||
|
MonitorID: "0",
|
||||||
|
Duration: "1h",
|
||||||
|
CustomHours: "12",
|
||||||
|
}
|
||||||
|
|
||||||
|
monitorOpts := []huh.Option[string]{huh.NewOption("All Monitors", "0")}
|
||||||
|
allSites := m.engine.GetAllSites()
|
||||||
|
for _, s := range allSites {
|
||||||
|
label := s.Name
|
||||||
|
if s.Type == "group" {
|
||||||
|
label = s.Name + " (group)"
|
||||||
|
}
|
||||||
|
monitorOpts = append(monitorOpts, huh.NewOption(label, strconv.Itoa(s.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.huhForm = huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().Title("Title").
|
||||||
|
Placeholder("DB Migration").
|
||||||
|
Value(&m.maintFormData.Title).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return fmt.Errorf("title is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
huh.NewSelect[string]().Title("Type").
|
||||||
|
Options(
|
||||||
|
huh.NewOption("Maintenance (suppress alerts)", "maintenance"),
|
||||||
|
huh.NewOption("Incident (informational)", "incident"),
|
||||||
|
).Value(&m.maintFormData.Type),
|
||||||
|
huh.NewSelect[string]().Title("Affected Monitors").
|
||||||
|
Options(monitorOpts...).
|
||||||
|
Value(&m.maintFormData.MonitorID),
|
||||||
|
huh.NewInput().Title("Description").
|
||||||
|
Placeholder("Optional notes").
|
||||||
|
Value(&m.maintFormData.Description),
|
||||||
|
).Title("Maintenance Window"),
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().Title("Duration").
|
||||||
|
Options(
|
||||||
|
huh.NewOption("1 hour", "1h"),
|
||||||
|
huh.NewOption("2 hours", "2h"),
|
||||||
|
huh.NewOption("4 hours", "4h"),
|
||||||
|
huh.NewOption("8 hours", "8h"),
|
||||||
|
huh.NewOption("Indefinite (end manually)", "indefinite"),
|
||||||
|
huh.NewOption("Custom", "custom"),
|
||||||
|
).Value(&m.maintFormData.Duration),
|
||||||
|
huh.NewInput().Title("Custom Duration (hours)").
|
||||||
|
Placeholder("12").
|
||||||
|
Value(&m.maintFormData.CustomHours).
|
||||||
|
Validate(func(s string) error {
|
||||||
|
if m.maintFormData.Duration != "custom" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("must be a number")
|
||||||
|
}
|
||||||
|
if v < 1 {
|
||||||
|
return fmt.Errorf("must be at least 1 hour")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
).Title("Duration").WithHideFunc(func() bool {
|
||||||
|
return m.maintFormData.Type == "incident"
|
||||||
|
}),
|
||||||
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
|
return m.huhForm.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) submitMaintForm() {
|
||||||
|
d := m.maintFormData
|
||||||
|
monitorID, _ := strconv.Atoi(d.MonitorID)
|
||||||
|
|
||||||
|
mw := models.MaintenanceWindow{
|
||||||
|
MonitorID: monitorID,
|
||||||
|
Title: d.Title,
|
||||||
|
Description: d.Description,
|
||||||
|
Type: d.Type,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.Type == "maintenance" {
|
||||||
|
switch d.Duration {
|
||||||
|
case "1h":
|
||||||
|
mw.EndTime = mw.StartTime.Add(1 * time.Hour)
|
||||||
|
case "2h":
|
||||||
|
mw.EndTime = mw.StartTime.Add(2 * time.Hour)
|
||||||
|
case "4h":
|
||||||
|
mw.EndTime = mw.StartTime.Add(4 * time.Hour)
|
||||||
|
case "8h":
|
||||||
|
mw.EndTime = mw.StartTime.Add(8 * time.Hour)
|
||||||
|
case "custom":
|
||||||
|
hours, _ := strconv.Atoi(d.CustomHours)
|
||||||
|
if hours < 1 {
|
||||||
|
hours = 1
|
||||||
|
}
|
||||||
|
mw.EndTime = mw.StartTime.Add(time.Duration(hours) * time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.store.AddMaintenanceWindow(mw); err != nil {
|
||||||
|
m.engine.AddLog("Add maintenance window failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.state = stateDashboard
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
+154
-16
@@ -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 {
|
||||||
@@ -207,14 +294,17 @@ func fmtRetries(site models.Site) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtStatus(status string, paused bool) string {
|
func fmtStatus(status string, paused bool, inMaint bool) string {
|
||||||
if paused {
|
if paused {
|
||||||
return warnStyle.Render("PAUSED")
|
return warnStyle.Render("PAUSED")
|
||||||
}
|
}
|
||||||
switch {
|
if inMaint {
|
||||||
case status == "DOWN" || status == "SSL EXP":
|
return maintStyle.Render("MAINT")
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
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)
|
||||||
@@ -224,7 +314,7 @@ func fmtStatus(status string, paused 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
|
||||||
}
|
}
|
||||||
@@ -280,10 +370,10 @@ func (m Model) viewSitesTab() string {
|
|||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
|
||||||
"group",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused),
|
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("—"),
|
||||||
})
|
})
|
||||||
@@ -313,7 +403,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), name),
|
||||||
typeIcon(site.Type, false) + " " + site.Type,
|
typeIcon(site.Type, false) + " " + site.Type,
|
||||||
fmtStatus(site.Status, site.Paused),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
fmtLatency(site.Latency),
|
fmtLatency(site.Latency),
|
||||||
fmtUptime(hist.Statuses),
|
fmtUptime(hist.Statuses),
|
||||||
spark,
|
spark,
|
||||||
@@ -616,14 +706,33 @@ 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))
|
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
||||||
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
|
row("Maintenance", maintStyle.Render(mw.Title))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
row("Type", site.Type)
|
row("Type", site.Type)
|
||||||
if site.URL != "" {
|
if site.URL != "" {
|
||||||
row("URL", site.URL)
|
row("URL", site.URL)
|
||||||
@@ -671,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+183
-44
@@ -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 (
|
||||||
@@ -42,6 +53,7 @@ const (
|
|||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
stateConfirmDelete
|
stateConfirmDelete
|
||||||
|
stateFormMaint
|
||||||
)
|
)
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
@@ -59,6 +71,7 @@ type Model struct {
|
|||||||
siteFormData *siteFormData
|
siteFormData *siteFormData
|
||||||
alertFormData *alertFormData
|
alertFormData *alertFormData
|
||||||
userFormData *userFormData
|
userFormData *userFormData
|
||||||
|
maintFormData *maintFormData
|
||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
@@ -78,10 +91,11 @@ type Model struct {
|
|||||||
pulseVel float64
|
pulseVel float64
|
||||||
tickCount int
|
tickCount int
|
||||||
|
|
||||||
sites []models.Site
|
sites []models.Site
|
||||||
alerts []models.AlertConfig
|
alerts []models.AlertConfig
|
||||||
users []models.User
|
users []models.User
|
||||||
nodes []models.ProbeNode
|
nodes []models.ProbeNode
|
||||||
|
maintenanceWindows []models.MaintenanceWindow
|
||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
@@ -92,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,
|
||||||
@@ -101,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 }))
|
||||||
}
|
}
|
||||||
@@ -128,7 +170,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.engine.AddLog("Delete alert failed: " + err.Error())
|
m.engine.AddLog("Delete alert failed: " + err.Error())
|
||||||
}
|
}
|
||||||
m.adjustCursor(len(m.alerts) - 1)
|
m.adjustCursor(len(m.alerts) - 1)
|
||||||
case 3:
|
case 4:
|
||||||
|
if err := m.store.DeleteMaintenanceWindow(m.deleteID); err != nil {
|
||||||
|
m.engine.AddLog("Delete maintenance window failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||||
|
case 5:
|
||||||
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
if err := m.store.DeleteUser(m.deleteID); err != nil {
|
||||||
m.engine.AddLog("Delete user failed: " + err.Error())
|
m.engine.AddLog("Delete user failed: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -136,12 +183,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
m.refreshData()
|
m.refreshData()
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 4 {
|
if m.deleteTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
case "n", "N", "esc":
|
case "n", "N", "esc":
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.deleteTab == 4 {
|
if m.deleteTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
case "ctrl+c":
|
case "ctrl+c":
|
||||||
@@ -152,7 +199,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
// Form state: forward ALL messages to huh (keys, timers, resize, etc.)
|
||||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser {
|
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||||
|
if wsm, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.termWidth = wsm.Width
|
||||||
|
m.termHeight = wsm.Height
|
||||||
|
}
|
||||||
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
if keyMsg, ok := msg.(tea.KeyMsg); ok {
|
||||||
if keyMsg.String() == "ctrl+c" {
|
if keyMsg.String() == "ctrl+c" {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -160,7 +211,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if keyMsg.String() == "esc" {
|
if keyMsg.String() == "esc" {
|
||||||
m.huhForm = nil
|
m.huhForm = nil
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 5 {
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -186,12 +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
|
||||||
m.maxTableRows = msg.Height - 12
|
chrome := chromeBase
|
||||||
|
if m.filterMode || m.filterText != "" {
|
||||||
|
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
|
m.logViewport.Width = msg.Width - chromePadH
|
||||||
m.logViewport.Height = msg.Height - 6
|
m.logViewport.Height = msg.Height - (chromePadV + chromeHeader + chromeGaps + chromeFooter)
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
case time.Time:
|
case time.Time:
|
||||||
@@ -209,18 +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)
|
||||||
|
case 5:
|
||||||
listLen = len(m.users)
|
listLen = len(m.users)
|
||||||
}
|
}
|
||||||
if msg.Button == tea.MouseButtonWheelUp {
|
if msg.Button == tea.MouseButtonWheelUp {
|
||||||
@@ -307,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 {
|
||||||
@@ -316,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 {
|
||||||
@@ -326,6 +384,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
max = len(m.nodes) - 1
|
max = len(m.nodes) - 1
|
||||||
}
|
}
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 4 {
|
||||||
|
max = len(m.maintenanceWindows) - 1
|
||||||
|
}
|
||||||
|
if m.currentTab == 5 {
|
||||||
max = len(m.users) - 1
|
max = len(m.users) - 1
|
||||||
}
|
}
|
||||||
if m.cursor < max {
|
if m.cursor < max {
|
||||||
@@ -344,7 +405,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
} else if m.currentTab == 1 {
|
} else if m.currentTab == 1 {
|
||||||
m.state = stateFormAlert
|
m.state = stateFormAlert
|
||||||
return m, m.initAlertHuhForm()
|
return m, m.initAlertHuhForm()
|
||||||
} else if m.currentTab == 4 && m.isAdmin {
|
} else if m.currentTab == 4 {
|
||||||
|
m.state = stateFormMaint
|
||||||
|
return m, m.initMaintHuhForm()
|
||||||
|
} else if m.currentTab == 5 && m.isAdmin {
|
||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
}
|
}
|
||||||
@@ -358,7 +422,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.editID = m.alerts[m.cursor].ID
|
m.editID = m.alerts[m.cursor].ID
|
||||||
m.state = stateFormAlert
|
m.state = stateFormAlert
|
||||||
return m, m.initAlertHuhForm()
|
return m, m.initAlertHuhForm()
|
||||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||||
m.editID = m.users[m.cursor].ID
|
m.editID = m.users[m.cursor].ID
|
||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
@@ -367,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":
|
||||||
@@ -381,6 +446,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
}
|
}
|
||||||
|
case "x":
|
||||||
|
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
|
mw := m.maintenanceWindows[m.cursor]
|
||||||
|
now := time.Now()
|
||||||
|
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||||
|
if isActive {
|
||||||
|
if err := m.store.EndMaintenanceWindow(mw.ID); err != nil {
|
||||||
|
m.engine.AddLog("End maintenance failed: " + err.Error())
|
||||||
|
}
|
||||||
|
m.refreshData()
|
||||||
|
}
|
||||||
|
}
|
||||||
case "d", "backspace":
|
case "d", "backspace":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
@@ -392,10 +469,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.deleteName = m.alerts[m.cursor].Name
|
m.deleteName = m.alerts[m.cursor].Name
|
||||||
m.deleteTab = 1
|
m.deleteTab = 1
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
|
} else if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
|
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||||
|
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||||
|
m.deleteTab = 4
|
||||||
|
m.state = stateConfirmDelete
|
||||||
|
} else if m.currentTab == 5 && m.isAdmin && len(m.users) > 0 {
|
||||||
m.deleteID = m.users[m.cursor].ID
|
m.deleteID = m.users[m.cursor].ID
|
||||||
m.deleteName = m.users[m.cursor].Username
|
m.deleteName = m.users[m.cursor].Username
|
||||||
m.deleteTab = 4
|
m.deleteTab = 5
|
||||||
m.state = stateConfirmDelete
|
m.state = stateConfirmDelete
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,9 +487,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
tabCount := 4
|
tabCount := 5
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
tabCount = 5
|
tabCount = 6
|
||||||
}
|
}
|
||||||
for i := 0; i < tabCount; i++ {
|
for i := 0; i < tabCount; i++ {
|
||||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||||
@@ -443,6 +525,19 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.currentTab == 4 {
|
if m.currentTab == 4 {
|
||||||
|
end := m.tableOffset + m.maxTableRows
|
||||||
|
if end > len(m.maintenanceWindows) {
|
||||||
|
end = len(m.maintenanceWindows)
|
||||||
|
}
|
||||||
|
for i := m.tableOffset; i < end; i++ {
|
||||||
|
if m.zones.Get(fmt.Sprintf("maint-%d", i)).InBounds(msg) {
|
||||||
|
m.cursor = i
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.currentTab == 5 {
|
||||||
end := m.tableOffset + m.maxTableRows
|
end := m.tableOffset + m.maxTableRows
|
||||||
if end > len(m.users) {
|
if end > len(m.users) {
|
||||||
end = len(m.users)
|
end = len(m.users)
|
||||||
@@ -459,9 +554,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) switchTab(idx int) {
|
func (m *Model) switchTab(idx int) {
|
||||||
maxTabs := 3
|
maxTabs := 4
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
maxTabs = 4
|
maxTabs = 5
|
||||||
}
|
}
|
||||||
if idx > maxTabs {
|
if idx > maxTabs {
|
||||||
idx = 0
|
idx = 0
|
||||||
@@ -472,7 +567,7 @@ func (m *Model) switchTab(idx int) {
|
|||||||
switch idx {
|
switch idx {
|
||||||
case 2:
|
case 2:
|
||||||
m.state = stateLogs
|
m.state = stateLogs
|
||||||
case 4:
|
case 5:
|
||||||
m.state = stateUsers
|
m.state = stateUsers
|
||||||
default:
|
default:
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
@@ -545,14 +640,20 @@ func (m *Model) refreshData() {
|
|||||||
if nodes, err := m.store.GetAllNodes(); err == nil {
|
if nodes, err := m.store.GetAllNodes(); err == nil {
|
||||||
m.nodes = nodes
|
m.nodes = nodes
|
||||||
}
|
}
|
||||||
|
if windows, err := m.store.GetAllMaintenanceWindows(100); err == nil {
|
||||||
|
m.maintenanceWindows = windows
|
||||||
|
}
|
||||||
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)
|
||||||
|
case 5:
|
||||||
listLen = len(m.users)
|
listLen = len(m.users)
|
||||||
}
|
}
|
||||||
if listLen > 0 && m.cursor >= listLen {
|
if listLen > 0 && m.cursor >= listLen {
|
||||||
@@ -577,6 +678,10 @@ func (m *Model) submitForm() {
|
|||||||
if m.userFormData != nil {
|
if m.userFormData != nil {
|
||||||
m.submitUserForm()
|
m.submitUserForm()
|
||||||
}
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
if m.maintFormData != nil {
|
||||||
|
m.submitMaintForm()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +693,7 @@ func (m Model) pulseIndicator() string {
|
|||||||
}
|
}
|
||||||
hasDown := false
|
hasDown := false
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
hasDown = true
|
hasDown = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -606,9 +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"
|
||||||
|
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))
|
||||||
@@ -619,7 +727,7 @@ func (m Model) View() string {
|
|||||||
Padding(1, 3).
|
Padding(1, 3).
|
||||||
Render(msg + "\n\n" + hint)
|
Render(msg + "\n\n" + hint)
|
||||||
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
||||||
case stateFormSite, stateFormAlert, stateFormUser:
|
case stateFormSite, stateFormAlert, stateFormUser, stateFormMaint:
|
||||||
if m.huhForm != nil {
|
if m.huhForm != nil {
|
||||||
title := ""
|
title := ""
|
||||||
switch m.state {
|
switch m.state {
|
||||||
@@ -638,7 +746,14 @@ func (m Model) View() string {
|
|||||||
if m.editID > 0 {
|
if m.editID > 0 {
|
||||||
title = fmt.Sprintf("Edit User #%d", m.editID)
|
title = fmt.Sprintf("Edit User #%d", m.editID)
|
||||||
}
|
}
|
||||||
|
case stateFormMaint:
|
||||||
|
title = "New Maintenance Window"
|
||||||
}
|
}
|
||||||
|
formHeight := m.termHeight - 7
|
||||||
|
if formHeight < 5 {
|
||||||
|
formHeight = 5
|
||||||
|
}
|
||||||
|
m.huhForm.WithHeight(formHeight)
|
||||||
header := titleStyle.Render(title)
|
header := titleStyle.Render(title)
|
||||||
footer := subtleStyle.Render("\n[Esc] Cancel")
|
footer := subtleStyle.Render("\n[Esc] Cancel")
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
|
||||||
@@ -652,9 +767,15 @@ 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.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
if s.Type == "group" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalMonitors++
|
||||||
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
downCount++
|
downCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -668,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"
|
||||||
}
|
}
|
||||||
@@ -682,7 +803,21 @@ func (m Model) viewDashboard() string {
|
|||||||
nodesLabel = "Nodes"
|
nodesLabel = "Nodes"
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel}
|
activeMaint := 0
|
||||||
|
for _, mw := range m.maintenanceWindows {
|
||||||
|
now := time.Now()
|
||||||
|
if !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now)) {
|
||||||
|
activeMaint++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var maintLabel string
|
||||||
|
if activeMaint > 0 {
|
||||||
|
maintLabel = fmt.Sprintf("Maint (%d)", activeMaint)
|
||||||
|
} else {
|
||||||
|
maintLabel = "Maint"
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel, maintLabel}
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
tabs = append(tabs, "Users")
|
tabs = append(tabs, "Users")
|
||||||
}
|
}
|
||||||
@@ -712,17 +847,19 @@ func (m Model) viewDashboard() string {
|
|||||||
case 3:
|
case 3:
|
||||||
content = m.viewNodesTab()
|
content = m.viewNodesTab()
|
||||||
case 4:
|
case 4:
|
||||||
|
content = m.viewMaintTab()
|
||||||
|
case 5:
|
||||||
if m.isAdmin {
|
if m.isAdmin {
|
||||||
content = m.viewUsersTab()
|
content = m.viewUsersTab()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
@@ -746,6 +883,8 @@ func (m Model) viewDashboard() string {
|
|||||||
case 0:
|
case 0:
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
|
||||||
case 4:
|
case 4:
|
||||||
|
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit"
|
||||||
|
case 5:
|
||||||
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
||||||
default:
|
default:
|
||||||
keys = "[Tab]Switch [q]Quit"
|
keys = "[Tab]Switch [q]Quit"
|
||||||
|
|||||||
Reference in New Issue
Block a user