Merge pull request 'release: 2026.05.1 — distributed probing, config-as-code, TUI polish' (#15) from develop into main

Reviewed-on: lerko/uptime#15
This commit is contained in:
2026-05-16 20:03:53 +00:00
38 changed files with 5400 additions and 1265 deletions
+1
View File
@@ -39,3 +39,4 @@ tmp
/go-upkeep/ /go-upkeep/
*.local.json *.local.json
*.local.md
+53 -52
View File
@@ -1,60 +1,63 @@
# Go-Upkeep # Go-Upkeep
![Go Version](https://img.shields.io/badge/go-1.23-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Docker](https://img.shields.io/docker/pulls/rdgames1000/go-upkeep) 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`.
**Go-Upkeep** is a self-hosted infrastructure monitor with a retro-futuristic TUI accessible via SSH. It supports High Availability, Push Monitoring, and Alerting. Originally forked from [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). This is an independent fork with significant additions.
* 🌐 **Full Documentation:** [goupkeep.org/docs](https://goupkeep.org/docs) ## What it does
* 🐳 **Docker Hub:** [rdgames1000/go-upkeep](https://hub.docker.com/r/rdgames1000/go-upkeep)
--- - **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups
- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
- **Config as code**: define monitors in YAML, apply declaratively, version control your setup
- **HA clustering**: leader/follower with automatic failover
- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
- **Public status page**: HTML + JSON, toggle with an env var
- **SQLite or Postgres**: SQLite for single-node, Postgres for production
- **Uptime Kuma import**: migrate from Kuma with one command
## 🚀 Key Features ## Quick start
* **SSH Dashboard**: Zero-install client. Manage monitors via `ssh -p 23234 your-server`.
* **Protocols**:
* **HTTP/S**: Active polling with SSL certificate expiration tracking.
* **PUSH**: Heartbeat endpoints for cron jobs/backup scripts.
* **High Availability**: Leader/Follower clustering with automatic failover.
* **Alerting**: Native support for Discord, Slack, Email (SMTP), and Webhooks.
* **Backends**: SQLite (default) or PostgreSQL (production).
---
## 🛠️ Quick Start (Local Dev)
**Option A: Native Go (Fastest)**
```bash ```bash
go mod tidy
go run cmd/goupkeep/main.go go run cmd/goupkeep/main.go
# Connect: ssh -p 23234 localhost ssh -p 23234 localhost
``` ```
**Option B: Docker Compose (Full Stack)** Seed some demo data to see it in action:
```bash ```bash
docker compose -f docker-compose.dev.yml up --build go run cmd/goupkeep/main.go -demo
``` ```
--- ## Config as code
## 📦 Production Deployment Export your current monitors:
For critical infrastructure, we recommend Docker Compose. ```bash
goupkeep export -o monitors.yaml
```
### 1. The Compose File Apply a config file:
Create `docker-compose.yml`:
```bash
goupkeep apply -f monitors.yaml
goupkeep apply -f monitors.yaml --dry-run # see what would change
goupkeep apply -f monitors.yaml --prune # delete anything not in the YAML
```
See [docs/config-as-code.md](docs/config-as-code.md) for the full reference.
## Docker
```yaml ```yaml
services: services:
monitor: monitor:
image: rdgames1000/go-upkeep:latest build: .
container_name: go-upkeep
restart: unless-stopped restart: unless-stopped
stdin_open: true # Required for initial setup console stdin_open: true
tty: true tty: true
ports: ports:
- "23234:23234" # SSH - "23234:23234"
- "8080:8080" # HTTP (Status Page & Push) - "8080:8080"
volumes: volumes:
- ./data:/data - ./data:/data
- ./ssh_keys:/app/.ssh - ./ssh_keys:/app/.ssh
@@ -62,28 +65,26 @@ services:
- UPKEEP_DB_TYPE=sqlite - UPKEEP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db - UPKEEP_DB_DSN=/data/upkeep.db
- UPKEEP_STATUS_ENABLED=true - UPKEEP_STATUS_ENABLED=true
- UPKEEP_CLUSTER_SECRET=ChangeMeToSomethingSecure - UPKEEP_CLUSTER_SECRET=change-me
``` ```
### 2. Initial Setup (Identity Management) First run: attach to the container (`docker attach go-upkeep`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
**Important:** V2 stores SSH keys in the database. You must create the first user manually via the console.
1. Start the stack: `docker compose up -d` ## Environment variables
2. Attach to the container: `docker attach go-upkeep`
3. Inside the TUI:
* Press **[Tab]** to select the `Users` tab.
* Press **[n]** to create a user.
* Enter your username and paste your public key (`cat ~/.ssh/id_ed25519.pub`).
* Press **[Enter]** to save.
4. Detach: Press **Ctrl+P** then **Ctrl+Q**.
### 3. Usage | Variable | Default | What it does |
Connect using your standard SSH client: |---|---|---|
```bash | `UPKEEP_PORT` | `23234` | SSH server port |
ssh -p 23234 your-server-ip | `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
``` | `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string |
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title |
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes |
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
For advanced setups (Postgres, Clustering, Migration), please consult the [Official Documentation](https://goupkeep.org/docs). ## License
## 📄 License MIT — see [LICENSE](LICENSE).
MIT License.
+186 -28
View File
@@ -1,9 +1,11 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"go-upkeep/internal/cluster" "go-upkeep/internal/cluster"
"go-upkeep/internal/config"
"go-upkeep/internal/importer" "go-upkeep/internal/importer"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
@@ -26,6 +28,102 @@ import (
func main() { func main() {
log.SetOutput(os.Stderr) log.SetOutput(os.Stderr)
if len(os.Args) >= 2 {
switch os.Args[1] {
case "apply":
runApply(os.Args[2:])
return
case "export":
runExport(os.Args[2:])
return
}
}
runServe(os.Args[1:])
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func openStore(dbType, dsn string) store.Store {
var s store.Store
var err error
if dbType == "postgres" {
s, err = store.NewPostgresStore(dsn)
} else {
s, err = store.NewSQLiteStore(dsn)
}
if err != nil {
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1)
}
if err := s.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
return s
}
func runApply(args []string) {
fs := flag.NewFlagSet("apply", flag.ExitOnError)
filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args)
if *filePath == "" {
fmt.Fprintln(os.Stderr, "error: -f flag is required")
fs.Usage()
os.Exit(1)
}
s := openStore(*dbType, *dsn)
f, err := config.LoadFile(*filePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
changes, err := config.Apply(s, f, config.ApplyOpts{
DryRun: *dryRun,
Prune: *prune,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Print(config.FormatChanges(changes, *dryRun))
}
func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
fs.Parse(args)
s := openStore(*dbType, *dsn)
f, err := config.Export(s)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if err := config.WriteFile(f, *outPath); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func runServe(args []string) {
portVal := 23234 portVal := 23234
dbType := "sqlite" dbType := "sqlite"
dbDSN := "upkeep.db" dbDSN := "upkeep.db"
@@ -58,7 +156,6 @@ func main() {
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" {
statusTitle = v statusTitle = v
} }
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" {
clusterMode = v clusterMode = v
} }
@@ -68,32 +165,71 @@ func main() {
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" {
clusterKey = v clusterKey = v
} }
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
monitor.SetInsecureSkipVerify(true) nodeID := os.Getenv("UPKEEP_NODE_ID")
nodeName := os.Getenv("UPKEEP_NODE_NAME")
nodeRegion := os.Getenv("UPKEEP_NODE_REGION")
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY")
if clusterMode == "probe" {
if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode")
os.Exit(1)
}
if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode")
os.Exit(1)
} }
port := flag.Int("port", portVal, "SSH Port") fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion)
flagDBType := flag.String("db-type", dbType, "Database type")
flagDSN := flag.String("dsn", dbDSN, "Database DSN") ctx, cancel := context.WithCancel(context.Background())
demo := flag.Bool("demo", false, "Seed demo data") done := make(chan os.Signal, 1)
importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file") signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
flag.Parse() go func() {
<-done
cancel()
}()
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
NodeID: nodeID,
NodeName: nodeName,
Region: nodeRegion,
LeaderURL: clusterPeer,
SharedKey: clusterKey,
Interval: 30,
}); err != nil {
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
}
return
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", portVal, "SSH Port")
flagDBType := fs.String("db-type", dbType, "Database type")
flagDSN := fs.String("dsn", dbDSN, "Database DSN")
demo := fs.Bool("demo", false, "Seed demo data")
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
fs.Parse(args)
var s store.Store var s store.Store
var dbErr error
if *flagDBType == "postgres" { if *flagDBType == "postgres" {
s = &store.PostgresStore{ConnStr: *flagDSN} s, dbErr = store.NewPostgresStore(*flagDSN)
fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) fmt.Printf("Using PostgreSQL: %s\n", *flagDSN)
} else { } else {
s = &store.SQLiteStore{DBPath: *flagDSN} s, dbErr = store.NewSQLiteStore(*flagDSN)
fmt.Printf("Using SQLite: %s\n", *flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN)
} }
if dbErr != nil {
fmt.Printf("Database connection error: %v\n", dbErr)
os.Exit(1)
}
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)
os.Exit(1) os.Exit(1)
} }
store.SetGlobal(s)
if *demo { if *demo {
seedDemoData(s) seedDemoData(s)
} }
@@ -112,25 +248,38 @@ func main() {
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
} }
monitor.StartEngine() eng := monitor.NewEngine(s)
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true)
}
if aggStrategy != "" {
eng.SetAggStrategy(monitor.AggregationStrategy(aggStrategy))
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
eng.InitHistory()
eng.InitLogs()
eng.Start(ctx)
server.Start(server.ServerConfig{ server.Start(server.ServerConfig{
Port: httpPort, Port: httpPort,
EnableStatus: enableStatus, EnableStatus: enableStatus,
Title: statusTitle, Title: statusTitle,
ClusterKey: clusterKey, ClusterKey: clusterKey,
}) }, s, eng)
cluster.Start(cluster.Config{ cluster.Start(ctx, cluster.Config{
Mode: clusterMode, Mode: clusterMode,
PeerURL: clusterPeer, PeerURL: clusterPeer,
SharedKey: clusterKey, SharedKey: clusterKey,
}) }, eng)
startSSHServer(*port) 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), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
} }
@@ -141,18 +290,19 @@ func main() {
<-done <-done
fmt.Println("Shutting down...") fmt.Println("Shutting down...")
} }
cancel()
} }
func startSSHServer(port int) { func startSSHServer(port int, db store.Store, eng *monitor.Engine) {
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"),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return isKeyAllowed(key) return isKeyAllowed(db, key)
}), }),
wish.WithMiddleware( wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return tui.InitialModel(false), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} return tui.InitialModel(false, db, eng), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
}), }),
), ),
) )
@@ -160,11 +310,16 @@ func startSSHServer(port int) {
fmt.Printf("SSH server error: %v\n", err) fmt.Printf("SSH server error: %v\n", err)
return return
} }
go func() { s.ListenAndServe() }() go func() {
if err := s.ListenAndServe(); err != nil {
log.Fatalf("SSH server failed: %v", err)
}
}()
} }
func seedDemoData(s store.Store) { func seedDemoData(s store.Store) {
if existing := s.GetSites(); len(existing) > 0 { existing, _ := s.GetSites()
if len(existing) > 0 {
return return
} }
fmt.Println("Seeding demo data...") fmt.Println("Seeding demo data...")
@@ -177,7 +332,7 @@ func seedDemoData(s store.Store) {
"from": "oncall@example.com", "to": "team@example.com", "from": "oncall@example.com", "to": "team@example.com",
}) })
alerts := s.GetAllAlerts() alerts, _ := s.GetAllAlerts()
alertID := 0 alertID := 0
if len(alerts) > 0 { if len(alerts) > 0 {
alertID = alerts[0].ID alertID = alerts[0].ID
@@ -195,8 +350,11 @@ func seedDemoData(s store.Store) {
s.AddSite(models.Site{Name: "SSH Server", Type: "port", Interval: 60, AlertID: alertID, Hostname: "10.0.0.1", Port: 22, Timeout: 5, 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})
} }
func isKeyAllowed(incomingKey ssh.PublicKey) bool { func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
users := store.Get().GetAllUsers() users, err := db.GetAllUsers()
if err != nil {
return false
}
for _, u := range users { for _, u := range users {
allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
if err != nil { if err != nil {
+35
View File
@@ -0,0 +1,35 @@
services:
leader:
build: .
environment:
- UPKEEP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=changeme
- UPKEEP_AGG_STRATEGY=any-down
- UPKEEP_STATUS_ENABLED=true
ports:
- "8080:8080"
- "23234:23234"
probe-us-east:
build: .
environment:
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=us-east-1
- UPKEEP_NODE_NAME=US East Probe
- UPKEEP_NODE_REGION=us-east
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
depends_on:
- leader
probe-eu-west:
build: .
environment:
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=eu-west-1
- UPKEEP_NODE_NAME=EU West Probe
- UPKEEP_NODE_REGION=eu-west
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
depends_on:
- leader
+244
View File
@@ -0,0 +1,244 @@
# Config as Code
Define your monitors and alerts in a YAML file. Version control them, copy them between instances, or spin up a fresh setup in one command.
## Quick start
Export what you already have:
```bash
goupkeep export -o monitors.yaml
```
That gives you a working file you can edit and re-apply:
```bash
goupkeep apply -f monitors.yaml
```
That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
## The YAML file
Two top-level sections: `alerts` and `monitors`. Alerts go first because monitors reference them by name.
```yaml
alerts:
- name: Discord Ops
type: discord
settings:
url: https://discord.com/api/webhooks/your/token
- name: PagerDuty Critical
type: pagerduty
settings:
routing_key: your-integration-key
severity: critical
monitors:
- name: API
type: http
url: https://api.example.com/health
interval: 30
alert: Discord Ops
- name: Production
type: group
alert: PagerDuty Critical
monitors:
- name: Prod Web
type: http
url: https://prod.example.com
interval: 15
- name: Prod DB
type: port
hostname: db.internal
port: 5432
interval: 30
```
## Monitor types
Each type has required fields. Everything else is optional with sensible defaults.
**http** — polls a URL
```yaml
- name: My API
type: http
url: https://api.example.com/health
interval: 30
```
Optional: `method` (default GET), `accepted_codes` (default 200-299), `timeout`, `check_ssl`, `expiry_threshold` (default 7 days), `max_retries`, `ignore_tls`, `description`, `paused`.
**ping** — ICMP ping a host
```yaml
- name: Gateway
type: ping
hostname: 10.0.0.1
interval: 30
```
**port** — check if a port is open
```yaml
- name: SSH Server
type: port
hostname: 10.0.0.1
port: 22
interval: 60
```
**dns** — resolve a hostname
```yaml
- name: DNS Check
type: dns
hostname: example.com
dns_resolve_type: A
dns_server: 1.1.1.1
interval: 60
```
**push** — heartbeat endpoint for cron jobs
```yaml
- name: Nightly Backup
type: push
interval: 86400
```
Push monitors get a token assigned automatically. Hit the push endpoint before the interval expires or it alerts.
**group** — organize monitors together
```yaml
- name: Production
type: group
monitors:
- name: Web
type: http
url: https://prod.example.com
interval: 15
```
Groups can't nest inside other groups. A group is healthy when all its children are healthy.
## Alert types
All 9 providers work in the YAML. The `settings` map is different per type.
```yaml
# Discord / Slack / Generic Webhook — just a URL
- name: Discord Ops
type: discord
settings:
url: https://discord.com/api/webhooks/your/token
# Email
- name: Email Oncall
type: email
settings:
host: smtp.example.com
port: "587"
user: oncall@example.com
pass: your-password
from: oncall@example.com
to: team@example.com
# Ntfy
- name: Ntfy Alerts
type: ntfy
settings:
url: https://ntfy.sh
topic: my-alerts
priority: "4"
# Telegram
- name: Telegram Ops
type: telegram
settings:
token: "123456:ABC-DEF..."
chat_id: "-1001234567890"
# PagerDuty
- name: PD Critical
type: pagerduty
settings:
routing_key: your-integration-key
severity: critical
# Pushover
- name: Pushover
type: pushover
settings:
token: app-token
user: user-key
# Gotify
- name: Gotify
type: gotify
settings:
url: https://gotify.example.com
token: app-token
priority: "8"
```
## Commands
**Export current state:**
```bash
goupkeep export -o monitors.yaml # to a file
goupkeep export # to stdout
```
**Apply a config:**
```bash
goupkeep apply -f monitors.yaml
```
**See what would change first:**
```bash
goupkeep apply -f monitors.yaml --dry-run
```
**Delete monitors not in the YAML:**
```bash
goupkeep apply -f monitors.yaml --prune
```
Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:**
```bash
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable"
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..."
```
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too.
## How apply works
Monitors and alerts are matched by **name**. Names must be unique across the entire file.
1. Alerts are resolved first (created or updated)
2. Groups are created next (so children can reference them)
3. Everything else is created or updated
4. If `--prune` is set, anything in the database that's not in the YAML gets deleted
Apply is idempotent. Run it twice with the same file, second run changes nothing.
If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
## Typical workflow
```bash
# set up your monitors in the TUI first, then export
goupkeep export -o monitors.yaml
# commit it
git add monitors.yaml && git commit -m "add monitor config"
# deploy to another instance
scp monitors.yaml prod-server:
ssh prod-server goupkeep apply -f monitors.yaml
# or just keep it as a backup you can restore from
goupkeep apply -f monitors.yaml
```
+1
View File
@@ -57,4 +57,5 @@ require (
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+1
View File
@@ -121,5 +121,6 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+118 -52
View File
@@ -7,6 +7,7 @@ import (
"go-upkeep/internal/models" "go-upkeep/internal/models"
"net/http" "net/http"
"net/smtp" "net/smtp"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -17,15 +18,95 @@ type Provider interface {
Send(title, message string) error Send(title, message string) error
} }
type PayloadFunc func(title, message string) ([]byte, error)
type HTTPProvider struct {
URL string
Payload PayloadFunc
}
func (h *HTTPProvider) Send(title, message string) error {
body, err := h.Payload(title, message)
if err != nil {
return err
}
resp, err := alertClient.Post(h.URL, "application/json", bytes.NewBuffer(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("alert webhook returned HTTP %d", resp.StatusCode)
}
return nil
}
func discordPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)})
}
func slackPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)})
}
func webhookPayload(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{"title": title, "message": message, "status": "alert"})
}
func telegramPayload(chatID string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{
"chat_id": chatID,
"text": fmt.Sprintf("*%s*\n%s", title, message),
"parse_mode": "Markdown",
})
}
}
func pagerdutyPayload(routingKey, severity string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]any{
"routing_key": routingKey,
"event_action": "trigger",
"payload": map[string]string{
"summary": fmt.Sprintf("%s: %s", title, message),
"source": "go-upkeep",
"severity": severity,
},
})
}
}
func pushoverPayload(token, user string) PayloadFunc {
return func(title, message string) ([]byte, error) {
return json.Marshal(map[string]string{
"token": token,
"user": user,
"title": title,
"message": message,
})
}
}
func gotifyPayload(priority string) PayloadFunc {
return func(title, message string) ([]byte, error) {
pri, _ := strconv.Atoi(priority)
return json.Marshal(map[string]any{
"title": title,
"message": message,
"priority": pri,
})
}
}
func GetProvider(cfg models.AlertConfig) Provider { func GetProvider(cfg models.AlertConfig) Provider {
switch cfg.Type { switch cfg.Type {
case "discord": case "discord":
return &DiscordProvider{URL: cfg.Settings["url"]} return &HTTPProvider{URL: cfg.Settings["url"], Payload: discordPayload}
case "slack": case "slack":
return &SlackProvider{URL: cfg.Settings["url"]} return &HTTPProvider{URL: cfg.Settings["url"], Payload: slackPayload}
case "webhook": case "webhook":
// Generic Webhook return &HTTPProvider{URL: cfg.Settings["url"], Payload: webhookPayload}
return &WebhookProvider{URL: cfg.Settings["url"]}
case "email": case "email":
port := "25" port := "25"
if p, ok := cfg.Settings["port"]; ok { if p, ok := cfg.Settings["port"]; ok {
@@ -51,58 +132,40 @@ func GetProvider(cfg models.AlertConfig) Provider {
Username: cfg.Settings["username"], Username: cfg.Settings["username"],
Password: cfg.Settings["password"], Password: cfg.Settings["password"],
} }
case "telegram":
return &HTTPProvider{
URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.Settings["token"]),
Payload: telegramPayload(cfg.Settings["chat_id"]),
}
case "pagerduty":
severity := "critical"
if s, ok := cfg.Settings["severity"]; ok && s != "" {
severity = s
}
return &HTTPProvider{
URL: "https://events.pagerduty.com/v2/enqueue",
Payload: pagerdutyPayload(cfg.Settings["routing_key"], severity),
}
case "pushover":
return &HTTPProvider{
URL: "https://api.pushover.net/1/messages.json",
Payload: pushoverPayload(cfg.Settings["token"], cfg.Settings["user"]),
}
case "gotify":
priority := "5"
if p, ok := cfg.Settings["priority"]; ok && p != "" {
priority = p
}
serverURL := strings.TrimRight(cfg.Settings["url"], "/")
return &HTTPProvider{
URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]),
Payload: gotifyPayload(priority),
}
default: default:
return nil return nil
} }
} }
// --- DISCORD ---
type DiscordProvider struct{ URL string }
func (d *DiscordProvider) Send(title, message string) error {
payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)}
jsonValue, _ := json.Marshal(payload)
resp, err := alertClient.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// --- SLACK ---
type SlackProvider struct{ URL string }
func (s *SlackProvider) Send(title, message string) error {
payload := map[string]string{"text": fmt.Sprintf("*%s*\n%s", title, message)}
jsonValue, _ := json.Marshal(payload)
resp, err := alertClient.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// --- GENERIC WEBHOOK ---
type WebhookProvider struct{ URL string }
func (w *WebhookProvider) Send(title, message string) error {
payload := map[string]string{
"title": title,
"message": message,
"status": "alert",
}
jsonValue, _ := json.Marshal(payload)
resp, err := alertClient.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// --- EMAIL ---
type EmailProvider struct { type EmailProvider struct {
Host, Port, User, Pass, To, From string Host, Port, User, Pass, To, From string
} }
@@ -139,6 +202,9 @@ func (n *NtfyProvider) Send(title, message string) error {
if err != nil { if err != nil {
return err return err
} }
resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode)
}
return nil return nil
} }
+213
View File
@@ -0,0 +1,213 @@
package alert
import (
"encoding/json"
"go-upkeep/internal/models"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPProviderDiscord(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()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Test Title", "Test Body"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["content"] != "**Test Title**\nTest Body" {
t.Errorf("unexpected payload: %s", received["content"])
}
}
func TestHTTPProviderSlack(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()
p := GetProvider(models.AlertConfig{Type: "slack", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Alert", "Message"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["text"] != "*Alert*\nMessage" {
t.Errorf("unexpected payload: %s", received["text"])
}
}
func TestHTTPProviderWebhook(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()
p := GetProvider(models.AlertConfig{Type: "webhook", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Title", "Body"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["title"] != "Title" || received["message"] != "Body" || received["status"] != "alert" {
t.Errorf("unexpected webhook payload: %v", received)
}
}
func TestHTTPProviderErrorOnHTTP4xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(403)
}))
defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "discord", Settings: map[string]string{"url": srv.URL}})
if err := p.Send("Test", "Test"); err == nil {
t.Fatal("expected error on 403 response")
}
}
func TestNtfyProvider(t *testing.T) {
var title, body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
title = r.Header.Get("Title")
buf := make([]byte, 1024)
n, _ := r.Body.Read(buf)
body = string(buf[:n])
w.WriteHeader(200)
}))
defer srv.Close()
p := GetProvider(models.AlertConfig{Type: "ntfy", Settings: map[string]string{
"url": srv.URL,
"topic": "test",
}})
if err := p.Send("Alert Title", "Alert Body"); err != nil {
t.Fatalf("Send: %v", err)
}
if title != "Alert Title" {
t.Errorf("expected title 'Alert Title', got '%s'", title)
}
if body != "Alert Body" {
t.Errorf("expected body 'Alert Body', got '%s'", body)
}
}
func TestHTTPProviderTelegram(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()
p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")}
if err := p.Send("Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["chat_id"] != "12345" {
t.Errorf("expected chat_id '12345', got '%s'", received["chat_id"])
}
if received["text"] != "*Alert*\nDown" {
t.Errorf("unexpected text: %s", received["text"])
}
if received["parse_mode"] != "Markdown" {
t.Errorf("expected parse_mode 'Markdown', got '%s'", received["parse_mode"])
}
}
func TestHTTPProviderPagerDuty(t *testing.T) {
var received map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(200)
}))
defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")}
if err := p.Send("Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["routing_key"] != "test-key" {
t.Errorf("expected routing_key 'test-key', got '%v'", received["routing_key"])
}
if received["event_action"] != "trigger" {
t.Errorf("expected event_action 'trigger', got '%v'", received["event_action"])
}
payload := received["payload"].(map[string]any)
if payload["summary"] != "Alert: Down" {
t.Errorf("unexpected summary: %v", payload["summary"])
}
if payload["severity"] != "critical" {
t.Errorf("expected severity 'critical', got '%v'", payload["severity"])
}
}
func TestHTTPProviderPushover(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()
p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")}
if err := p.Send("Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["token"] != "app-tok" {
t.Errorf("expected token 'app-tok', got '%s'", received["token"])
}
if received["user"] != "user-key" {
t.Errorf("expected user 'user-key', got '%s'", received["user"])
}
if received["title"] != "Alert" || received["message"] != "Down" {
t.Errorf("unexpected payload: %v", received)
}
}
func TestHTTPProviderGotify(t *testing.T) {
var received map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&received)
w.WriteHeader(200)
}))
defer srv.Close()
p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")}
if err := p.Send("Alert", "Down"); err != nil {
t.Fatalf("Send: %v", err)
}
if received["title"] != "Alert" || received["message"] != "Down" {
t.Errorf("unexpected payload: %v", received)
}
if pri, ok := received["priority"].(float64); !ok || pri != 8 {
t.Errorf("expected priority 8, got %v", received["priority"])
}
}
func TestGetProviderNewTypes(t *testing.T) {
for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} {
p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{
"token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost",
}})
if p == nil {
t.Errorf("GetProvider(%q) returned nil", typ)
}
}
}
func TestGetProviderUnknown(t *testing.T) {
p := GetProvider(models.AlertConfig{Type: "unknown"})
if p != nil {
t.Error("expected nil for unknown provider type")
}
}
+19 -16
View File
@@ -1,6 +1,7 @@
package cluster package cluster
import ( import (
"context"
"fmt" "fmt"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"net/http" "net/http"
@@ -14,13 +15,13 @@ type Config struct {
SharedKey string // Security Key SharedKey string // Security Key
} }
func Start(cfg Config) { func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
if cfg.Mode == "leader" { if cfg.Mode == "leader" {
fmt.Println("Cluster: Running as LEADER (Active)") fmt.Println("Cluster: Running as LEADER (Active)")
if cfg.SharedKey != "" { if cfg.SharedKey != "" {
fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.") fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.")
} }
monitor.SetEngineActive(true) eng.SetActive(true)
return return
} }
@@ -29,20 +30,24 @@ func Start(cfg Config) {
if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") { if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") {
fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.") fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.")
} }
monitor.SetEngineActive(false) eng.SetActive(false)
go runFollowerLoop(cfg) go runFollowerLoop(ctx, cfg, eng)
} }
// "probe" mode is handled directly in main.go before cluster.Start is called
} }
func runFollowerLoop(cfg Config) { func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
client := http.Client{Timeout: 2 * time.Second} client := http.Client{Timeout: 2 * time.Second}
// Failover Configuration
failures := 0 failures := 0
threshold := 3 threshold := 3
for { for {
time.Sleep(5 * time.Second) select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil) req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
if cfg.SharedKey != "" { if cfg.SharedKey != "" {
@@ -59,17 +64,15 @@ func runFollowerLoop(cfg Config) {
if isLeaderHealthy { if isLeaderHealthy {
failures = 0 failures = 0
if monitor.IsEngineActive() { if eng.IsActive() {
// Leader is back, yield eng.SetActive(false)
monitor.SetEngineActive(false) eng.AddLog("Cluster: Leader detected. Switching to PASSIVE.")
monitor.AddLog("Cluster: Leader detected. Switching to PASSIVE.")
} }
} else { } else {
failures++ failures++
// If failures exceed threshold, take over if failures >= threshold && !eng.IsActive() {
if failures >= threshold && !monitor.IsEngineActive() { eng.SetActive(true)
monitor.SetEngineActive(true) eng.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.")
monitor.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.")
} }
} }
} }
+187
View File
@@ -0,0 +1,187 @@
package cluster
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"log"
"net/http"
"sync"
"time"
)
type ProbeConfig struct {
NodeID string
NodeName string
Region string
LeaderURL string
SharedKey string
Interval int
}
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
if cfg.Interval < 10 {
cfg.Interval = 30
}
apiClient := &http.Client{Timeout: 10 * time.Second}
strictClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
}
insecureClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
if err := probeRegister(ctx, apiClient, cfg); err != nil {
log.Printf("Probe: initial registration failed: %v (will retry)", err)
}
for {
select {
case <-ctx.Done():
return nil
default:
}
sites, err := probeFetchAssignments(ctx, apiClient, cfg)
if err != nil {
log.Printf("Probe: failed to fetch assignments: %v", err)
sleepCtx(ctx, 10*time.Second)
continue
}
if len(sites) == 0 {
sleepCtx(ctx, time.Duration(cfg.Interval)*time.Second)
continue
}
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient)
if len(results) > 0 {
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
log.Printf("Probe: failed to report results: %v", err)
}
}
sleepCtx(ctx, time.Duration(cfg.Interval)*time.Second)
}
}
func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) error {
body, _ := json.Marshal(map[string]string{
"id": cfg.NodeID, "name": cfg.NodeName, "region": cfg.Region, "version": "probe",
})
req, err := http.NewRequestWithContext(ctx, "POST", cfg.LeaderURL+"/api/probe/register", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("register returned %d", resp.StatusCode)
}
return nil
}
func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) {
req, err := http.NewRequestWithContext(ctx, "GET", cfg.LeaderURL+"/api/probe/assignments?node_id="+cfg.NodeID, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("assignments returned %d", resp.StatusCode)
}
var result struct {
Sites []models.Site `json:"sites"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Sites, nil
}
type probeResultItem struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
}
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client) []probeResultItem {
var mu sync.Mutex
var results []probeResultItem
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, site := range sites {
select {
case <-ctx.Done():
break
default:
}
wg.Add(1)
sem <- struct{}{}
go func(s models.Site) {
defer wg.Done()
defer func() { <-sem }()
cr := monitor.RunCheck(s, strict, insecure, false)
mu.Lock()
results = append(results, probeResultItem{
SiteID: s.ID,
LatencyNs: cr.LatencyNs,
IsUp: cr.Status == "UP",
})
mu.Unlock()
}(site)
}
wg.Wait()
return results
}
func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfig, results []probeResultItem) error {
body, err := json.Marshal(map[string]interface{}{
"node_id": cfg.NodeID,
"results": results,
})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", cfg.LeaderURL+"/api/probe/results", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("results returned %d", resp.StatusCode)
}
fmt.Printf("Probe: reported %d check results\n", len(results))
return nil
}
func sleepCtx(ctx context.Context, d time.Duration) {
select {
case <-time.After(d):
case <-ctx.Done():
}
}
+396
View File
@@ -0,0 +1,396 @@
package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"reflect"
"strings"
)
type ApplyOpts struct {
DryRun bool
Prune bool
}
type Change struct {
Action string
Kind string
Name string
Details string
}
func Apply(s store.Store, f *File, opts ApplyOpts) ([]Change, error) {
if err := Validate(f); err != nil {
return nil, err
}
existingAlerts, err := s.GetAllAlerts()
if err != nil {
return nil, fmt.Errorf("load alerts: %w", err)
}
existingSites, err := s.GetSites()
if err != nil {
return nil, fmt.Errorf("load sites: %w", err)
}
existingAlertsByName := make(map[string]models.AlertConfig, len(existingAlerts))
for _, a := range existingAlerts {
existingAlertsByName[a.Name] = a
}
existingSitesByName := make(map[string]models.Site, len(existingSites))
for _, s := range existingSites {
existingSitesByName[s.Name] = s
}
var changes []Change
alertMap := make(map[string]int)
for _, ea := range existingAlerts {
alertMap[ea.Name] = ea.ID
}
desiredAlertNames := make(map[string]bool, len(f.Alerts))
for _, a := range f.Alerts {
desiredAlertNames[a.Name] = true
existing, exists := existingAlertsByName[a.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "alert", Name: a.Name, Details: a.Type})
if !opts.DryRun {
id, err := s.AddAlertReturningID(a.Name, a.Type, a.Settings)
if err != nil {
return changes, fmt.Errorf("create alert %q: %w", a.Name, err)
}
alertMap[a.Name] = id
}
} else {
alertMap[a.Name] = existing.ID
if diff := diffAlert(existing, a); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "alert", Name: a.Name, Details: diff})
if !opts.DryRun {
if err := s.UpdateAlert(existing.ID, a.Name, a.Type, a.Settings); err != nil {
return changes, fmt.Errorf("update alert %q: %w", a.Name, err)
}
}
}
}
}
desiredMonitorNames := make(map[string]bool)
collectMonitorNames(f.Monitors, desiredMonitorNames)
var groups []Monitor
var topLevel []Monitor
for _, m := range f.Monitors {
if m.Type == "group" {
groups = append(groups, m)
} else {
topLevel = append(topLevel, m)
}
}
groupMap := make(map[string]int)
for _, g := range groups {
alertID, err := resolveAlertID(alertMap, g.Alert)
if err != nil {
return changes, fmt.Errorf("monitor %q: %w", g.Name, err)
}
site := monitorToSite(g, alertID, 0)
existing, exists := existingSitesByName[g.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: g.Name, Details: "group"})
if !opts.DryRun {
id, err := s.AddSiteReturningID(site)
if err != nil {
return changes, fmt.Errorf("create group %q: %w", g.Name, err)
}
groupMap[g.Name] = id
}
} else {
groupMap[g.Name] = existing.ID
site.ID = existing.ID
if diff := diffSite(normalizeSite(existing), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: g.Name, Details: diff})
if !opts.DryRun {
if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update group %q: %w", g.Name, err)
}
}
}
}
}
for _, g := range groups {
parentID := groupMap[g.Name]
for _, child := range g.Monitors {
c, err := applyMonitor(s, child, alertMap, existingSitesByName, parentID, opts.DryRun)
if err != nil {
return changes, err
}
changes = append(changes, c...)
}
}
for _, m := range topLevel {
c, err := applyMonitor(s, m, alertMap, existingSitesByName, 0, opts.DryRun)
if err != nil {
return changes, err
}
changes = append(changes, c...)
}
if opts.Prune {
var childDeletes []Change
var groupDeletes []Change
for _, es := range existingSites {
if desiredMonitorNames[es.Name] {
continue
}
c := Change{Action: "delete", Kind: "monitor", Name: es.Name, Details: es.Type}
if es.Type == "group" {
groupDeletes = append(groupDeletes, c)
} else {
childDeletes = append(childDeletes, c)
}
if !opts.DryRun {
if err := s.DeleteSite(es.ID); err != nil {
return changes, fmt.Errorf("delete monitor %q: %w", es.Name, err)
}
}
}
changes = append(changes, childDeletes...)
changes = append(changes, groupDeletes...)
for _, ea := range existingAlerts {
if desiredAlertNames[ea.Name] {
continue
}
changes = append(changes, Change{Action: "delete", Kind: "alert", Name: ea.Name, Details: ea.Type})
if !opts.DryRun {
if err := s.DeleteAlert(ea.ID); err != nil {
return changes, fmt.Errorf("delete alert %q: %w", ea.Name, err)
}
}
}
}
return changes, nil
}
func applyMonitor(s store.Store, m Monitor, alertMap map[string]int, existing map[string]models.Site, parentID int, dryRun bool) ([]Change, error) {
alertID, err := resolveAlertID(alertMap, m.Alert)
if err != nil {
return nil, fmt.Errorf("monitor %q: %w", m.Name, err)
}
site := monitorToSite(m, alertID, parentID)
var changes []Change
ex, exists := existing[m.Name]
if !exists {
changes = append(changes, Change{Action: "create", Kind: "monitor", Name: m.Name, Details: m.Type})
if !dryRun {
if _, err := s.AddSiteReturningID(site); err != nil {
return changes, fmt.Errorf("create monitor %q: %w", m.Name, err)
}
}
} else {
site.ID = ex.ID
if diff := diffSite(normalizeSite(ex), site); diff != "" {
changes = append(changes, Change{Action: "update", Kind: "monitor", Name: m.Name, Details: diff})
if !dryRun {
if err := s.UpdateSite(site); err != nil {
return changes, fmt.Errorf("update monitor %q: %w", m.Name, err)
}
}
}
}
return changes, nil
}
func resolveAlertID(alertMap map[string]int, name string) (int, error) {
if name == "" {
return 0, nil
}
id, ok := alertMap[name]
if !ok {
return 0, fmt.Errorf("alert %q not found", name)
}
return id, nil
}
func monitorToSite(m Monitor, alertID, parentID int) models.Site {
s := models.Site{
Name: m.Name,
Type: m.Type,
URL: m.URL,
Interval: m.Interval,
AlertID: alertID,
ParentID: parentID,
CheckSSL: m.CheckSSL,
MaxRetries: m.MaxRetries,
Hostname: m.Hostname,
Port: m.Port,
Timeout: m.Timeout,
Description: m.Description,
DNSResolveType: m.DNSResolveType,
DNSServer: m.DNSServer,
IgnoreTLS: m.IgnoreTLS,
Paused: m.Paused,
Regions: m.Regions,
}
s.ExpiryThreshold = m.ExpiryThreshold
if s.ExpiryThreshold == 0 {
s.ExpiryThreshold = 7
}
s.Method = m.Method
if s.Method == "" {
s.Method = "GET"
}
s.AcceptedCodes = m.AcceptedCodes
if s.AcceptedCodes == "" {
s.AcceptedCodes = "200-299"
}
return s
}
func collectMonitorNames(monitors []Monitor, names map[string]bool) {
for _, m := range monitors {
names[m.Name] = true
collectMonitorNames(m.Monitors, names)
}
}
func normalizeSite(s models.Site) models.Site {
if s.Method == "" {
s.Method = "GET"
}
if s.AcceptedCodes == "" {
s.AcceptedCodes = "200-299"
}
if s.ExpiryThreshold == 0 {
s.ExpiryThreshold = 7
}
return s
}
func diffAlert(existing models.AlertConfig, desired Alert) string {
var diffs []string
if existing.Type != desired.Type {
diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type))
}
if !reflect.DeepEqual(existing.Settings, desired.Settings) {
diffs = append(diffs, "settings changed")
}
return strings.Join(diffs, ", ")
}
func diffSite(existing, desired models.Site) string {
var diffs []string
if existing.URL != desired.URL {
diffs = append(diffs, fmt.Sprintf("url: %s -> %s", existing.URL, desired.URL))
}
if existing.Type != desired.Type {
diffs = append(diffs, fmt.Sprintf("type: %s -> %s", existing.Type, desired.Type))
}
if existing.Interval != desired.Interval {
diffs = append(diffs, fmt.Sprintf("interval: %d -> %d", existing.Interval, desired.Interval))
}
if existing.AlertID != desired.AlertID {
diffs = append(diffs, fmt.Sprintf("alert_id: %d -> %d", existing.AlertID, desired.AlertID))
}
if existing.CheckSSL != desired.CheckSSL {
diffs = append(diffs, fmt.Sprintf("check_ssl: %v -> %v", existing.CheckSSL, desired.CheckSSL))
}
if existing.ExpiryThreshold != desired.ExpiryThreshold {
diffs = append(diffs, fmt.Sprintf("expiry_threshold: %d -> %d", existing.ExpiryThreshold, desired.ExpiryThreshold))
}
if existing.MaxRetries != desired.MaxRetries {
diffs = append(diffs, fmt.Sprintf("max_retries: %d -> %d", existing.MaxRetries, desired.MaxRetries))
}
if existing.Hostname != desired.Hostname {
diffs = append(diffs, fmt.Sprintf("hostname: %s -> %s", existing.Hostname, desired.Hostname))
}
if existing.Port != desired.Port {
diffs = append(diffs, fmt.Sprintf("port: %d -> %d", existing.Port, desired.Port))
}
if existing.Timeout != desired.Timeout {
diffs = append(diffs, fmt.Sprintf("timeout: %d -> %d", existing.Timeout, desired.Timeout))
}
if existing.Method != desired.Method {
diffs = append(diffs, fmt.Sprintf("method: %s -> %s", existing.Method, desired.Method))
}
if existing.Description != desired.Description {
diffs = append(diffs, "description changed")
}
if existing.ParentID != desired.ParentID {
diffs = append(diffs, fmt.Sprintf("parent_id: %d -> %d", existing.ParentID, desired.ParentID))
}
if existing.AcceptedCodes != desired.AcceptedCodes {
diffs = append(diffs, fmt.Sprintf("accepted_codes: %s -> %s", existing.AcceptedCodes, desired.AcceptedCodes))
}
if existing.DNSResolveType != desired.DNSResolveType {
diffs = append(diffs, fmt.Sprintf("dns_resolve_type: %s -> %s", existing.DNSResolveType, desired.DNSResolveType))
}
if existing.DNSServer != desired.DNSServer {
diffs = append(diffs, fmt.Sprintf("dns_server: %s -> %s", existing.DNSServer, desired.DNSServer))
}
if existing.IgnoreTLS != desired.IgnoreTLS {
diffs = append(diffs, fmt.Sprintf("ignore_tls: %v -> %v", existing.IgnoreTLS, desired.IgnoreTLS))
}
if existing.Paused != desired.Paused {
diffs = append(diffs, fmt.Sprintf("paused: %v -> %v", existing.Paused, desired.Paused))
}
if existing.Regions != desired.Regions {
diffs = append(diffs, fmt.Sprintf("regions: %s -> %s", existing.Regions, desired.Regions))
}
return strings.Join(diffs, ", ")
}
func FormatChanges(changes []Change, dryRun bool) string {
var b strings.Builder
if dryRun {
b.WriteString("Dry run — no changes applied.\n\n")
}
if len(changes) == 0 {
b.WriteString("No changes needed. State is up to date.\n")
return b.String()
}
creates, updates, deletes := 0, 0, 0
for _, c := range changes {
var prefix string
switch c.Action {
case "create":
prefix = " + create"
creates++
case "update":
prefix = " ~ update"
updates++
case "delete":
prefix = " - delete"
deletes++
}
line := fmt.Sprintf("%s %s %q", prefix, c.Kind, c.Name)
if c.Details != "" {
line += " (" + c.Details + ")"
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
if dryRun {
fmt.Fprintf(&b, "Summary: %d to create, %d to update, %d to delete\n", creates, updates, deletes)
} else {
total := creates + updates + deletes
fmt.Fprintf(&b, "Applied %d changes (%d created, %d updated, %d deleted)\n", total, creates, updates, deletes)
}
return b.String()
}
+290
View File
@@ -0,0 +1,290 @@
package config
import (
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"strings"
"testing"
)
func newTestStore(t *testing.T) store.Store {
t.Helper()
s, err := store.NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return s
}
func TestApplyCreateFromScratch(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 3 {
t.Fatalf("expected 3 creates, got %d", creates)
}
sites, _ := s.GetSites()
if len(sites) != 2 {
t.Fatalf("expected 2 sites, got %d", len(sites))
}
alerts, _ := s.GetAllAlerts()
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
}
func TestApplyIdempotent(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err)
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
if len(changes) != 0 {
t.Fatalf("expected 0 changes on second apply, got %d: %+v", len(changes), changes)
}
}
func TestApplyUpdate(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("first Apply: %v", err)
}
f.Monitors[0].Interval = 60
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "update" {
t.Fatalf("expected 1 update, got %+v", changes)
}
sites, _ := s.GetSites()
if sites[0].Interval != 60 {
t.Fatalf("expected interval 60, got %d", sites[0].Interval)
}
}
func TestApplyPrune(t *testing.T) {
s := newTestStore(t)
s.AddSite(models.Site{Name: "Keep", URL: "https://keep.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Remove", URL: "https://remove.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f := &File{
Monitors: []Monitor{
{Name: "Keep", Type: "http", URL: "https://keep.com", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{Prune: true})
if err != nil {
t.Fatalf("Apply: %v", err)
}
deleteCount := 0
for _, c := range changes {
if c.Action == "delete" {
deleteCount++
}
}
if deleteCount != 1 {
t.Fatalf("expected 1 delete, got %d", deleteCount)
}
sites, _ := s.GetSites()
if len(sites) != 1 || sites[0].Name != "Keep" {
t.Fatalf("expected only 'Keep', got %+v", sites)
}
}
func TestApplyDryRun(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30},
},
}
changes, err := Apply(s, f, ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("expected 1 create in dry-run, got %+v", changes)
}
sites, _ := s.GetSites()
if len(sites) != 0 {
t.Fatalf("expected 0 sites after dry-run, got %d", len(sites))
}
}
func TestApplyGroupHierarchy(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15},
{Name: "Prod DB", Type: "port", Hostname: "db.internal", Port: 5432, Interval: 30},
},
},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 3 {
t.Fatalf("expected 3 creates, got %d", len(changes))
}
sites, _ := s.GetSites()
var group models.Site
for _, s := range sites {
if s.Type == "group" {
group = s
break
}
}
if group.ID == 0 {
t.Fatal("group not found")
}
childCount := 0
for _, s := range sites {
if s.ParentID == group.ID {
childCount++
}
}
if childCount != 2 {
t.Fatalf("expected 2 children, got %d", childCount)
}
}
func TestApplyAlertReference(t *testing.T) {
s := newTestStore(t)
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
},
}
if _, err := Apply(s, f, ApplyOpts{}); err != nil {
t.Fatalf("Apply: %v", err)
}
sites, _ := s.GetSites()
alerts, _ := s.GetAllAlerts()
if sites[0].AlertID != alerts[0].ID {
t.Fatalf("expected alert_id %d, got %d", alerts[0].ID, sites[0].AlertID)
}
}
func TestApplyInvalidAlertRef(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Nonexistent"},
},
}
_, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected alert not found error, got %v", err)
}
}
func TestApplyDuplicateNames(t *testing.T) {
s := newTestStore(t)
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://a.com", Interval: 30},
{Name: "Web", Type: "http", URL: "https://b.com", Interval: 30},
},
}
_, err := Apply(s, f, ApplyOpts{})
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate error, got %v", err)
}
}
func TestApplyExistingAlertReference(t *testing.T) {
s := newTestStore(t)
s.AddAlert("Existing", "webhook", map[string]string{"url": "https://example.com"})
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Existing"},
},
}
changes, err := Apply(s, f, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(changes) != 1 || changes[0].Action != "create" {
t.Fatalf("expected 1 create, got %+v", changes)
}
sites, _ := s.GetSites()
if sites[0].AlertID == 0 {
t.Fatal("expected non-zero alert_id for existing alert reference")
}
}
+158
View File
@@ -0,0 +1,158 @@
package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"os"
"sort"
"gopkg.in/yaml.v3"
)
func Export(s store.Store) (*File, error) {
dbAlerts, err := s.GetAllAlerts()
if err != nil {
return nil, fmt.Errorf("load alerts: %w", err)
}
dbSites, err := s.GetSites()
if err != nil {
return nil, fmt.Errorf("load sites: %w", err)
}
alertIDToName := make(map[int]string, len(dbAlerts))
var yamlAlerts []Alert
for _, a := range dbAlerts {
alertIDToName[a.ID] = a.Name
yamlAlerts = append(yamlAlerts, Alert{
Name: a.Name,
Type: a.Type,
Settings: a.Settings,
})
}
groups := make(map[int]models.Site)
children := make(map[int][]models.Site)
var topLevel []models.Site
for _, s := range dbSites {
switch {
case s.Type == "group":
groups[s.ID] = s
case s.ParentID > 0:
children[s.ParentID] = append(children[s.ParentID], s)
default:
topLevel = append(topLevel, s)
}
}
var yamlMonitors []Monitor
groupIDs := make([]int, 0, len(groups))
for id := range groups {
groupIDs = append(groupIDs, id)
}
sort.Ints(groupIDs)
for _, gid := range groupIDs {
g := groups[gid]
ym := siteToMonitor(g, alertIDToName)
kids := children[gid]
sort.Slice(kids, func(i, j int) bool { return kids[i].ID < kids[j].ID })
for _, child := range kids {
ym.Monitors = append(ym.Monitors, siteToMonitor(child, alertIDToName))
}
yamlMonitors = append(yamlMonitors, ym)
}
sort.Slice(topLevel, func(i, j int) bool { return topLevel[i].ID < topLevel[j].ID })
for _, s := range topLevel {
yamlMonitors = append(yamlMonitors, siteToMonitor(s, alertIDToName))
}
return &File{Alerts: yamlAlerts, Monitors: yamlMonitors}, nil
}
func siteToMonitor(s models.Site, alertIDToName map[int]string) Monitor {
m := Monitor{
Name: s.Name,
Type: s.Type,
Interval: s.Interval,
}
if s.AlertID > 0 {
if name, ok := alertIDToName[s.AlertID]; ok {
m.Alert = name
}
}
if s.URL != "" {
m.URL = s.URL
}
if s.Hostname != "" {
m.Hostname = s.Hostname
}
if s.Port != 0 {
m.Port = s.Port
}
if s.Timeout != 0 {
m.Timeout = s.Timeout
}
if s.Description != "" {
m.Description = s.Description
}
if s.DNSResolveType != "" {
m.DNSResolveType = s.DNSResolveType
}
if s.DNSServer != "" {
m.DNSServer = s.DNSServer
}
if s.Method != "" && s.Method != "GET" {
m.Method = s.Method
}
if s.AcceptedCodes != "" && s.AcceptedCodes != "200-299" {
m.AcceptedCodes = s.AcceptedCodes
}
if s.ExpiryThreshold != 0 && s.ExpiryThreshold != 7 {
m.ExpiryThreshold = s.ExpiryThreshold
}
if s.MaxRetries != 0 {
m.MaxRetries = s.MaxRetries
}
m.CheckSSL = s.CheckSSL
m.IgnoreTLS = s.IgnoreTLS
m.Paused = s.Paused
if s.Regions != "" {
m.Regions = s.Regions
}
return m
}
func WriteFile(f *File, path string) error {
data, err := yaml.Marshal(f)
if err != nil {
return fmt.Errorf("marshal yaml: %w", err)
}
if path == "-" || path == "" {
_, err = os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0644)
}
func LoadFile(path string) (*File, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var f File
if err := yaml.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &f, nil
}
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"go-upkeep/internal/models"
"testing"
)
func TestExportEmpty(t *testing.T) {
s := newTestStore(t)
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Alerts) != 0 || len(f.Monitors) != 0 {
t.Fatalf("expected empty file, got %d alerts %d monitors", len(f.Alerts), len(f.Monitors))
}
}
func TestExportAlertNames(t *testing.T) {
s := newTestStore(t)
s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s.GetAllAlerts()
s.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Monitors) != 1 {
t.Fatalf("expected 1 monitor, got %d", len(f.Monitors))
}
if f.Monitors[0].Alert != "Discord" {
t.Fatalf("expected alert name 'Discord', got %q", f.Monitors[0].Alert)
}
}
func TestExportGroupHierarchy(t *testing.T) {
s := newTestStore(t)
groupID, _ := s.AddSiteReturningID(models.Site{Name: "Prod", Type: "group", ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Prod Web", URL: "https://prod.example.com", Type: "http", Interval: 15, ParentID: groupID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s.AddSite(models.Site{Name: "Top Level", URL: "https://example.com", Type: "http", Interval: 30, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
if len(f.Monitors) != 2 {
t.Fatalf("expected 2 top-level monitors, got %d", len(f.Monitors))
}
var group *Monitor
for i := range f.Monitors {
if f.Monitors[i].Type == "group" {
group = &f.Monitors[i]
break
}
}
if group == nil {
t.Fatal("group not found in export")
}
if len(group.Monitors) != 1 {
t.Fatalf("expected 1 child in group, got %d", len(group.Monitors))
}
if group.Monitors[0].Name != "Prod Web" {
t.Fatalf("expected child 'Prod Web', got %q", group.Monitors[0].Name)
}
}
func TestExportOmitsDefaults(t *testing.T) {
s := newTestStore(t)
s.AddSite(models.Site{
Name: "Web", URL: "https://example.com", Type: "http", Interval: 30,
Method: "GET", AcceptedCodes: "200-299", ExpiryThreshold: 7,
})
f, err := Export(s)
if err != nil {
t.Fatalf("Export: %v", err)
}
m := f.Monitors[0]
if m.Method != "" {
t.Errorf("expected empty method (default omitted), got %q", m.Method)
}
if m.AcceptedCodes != "" {
t.Errorf("expected empty accepted_codes (default omitted), got %q", m.AcceptedCodes)
}
if m.ExpiryThreshold != 0 {
t.Errorf("expected 0 expiry_threshold (default omitted), got %d", m.ExpiryThreshold)
}
}
func TestExportRoundTrip(t *testing.T) {
s1 := newTestStore(t)
s1.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com"})
alerts, _ := s1.GetAllAlerts()
s1.AddSite(models.Site{Name: "Web", URL: "https://example.com", Type: "http", Interval: 30, AlertID: alerts[0].ID, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
s1.AddSite(models.Site{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 60, ExpiryThreshold: 7, Method: "GET", AcceptedCodes: "200-299"})
exported, err := Export(s1)
if err != nil {
t.Fatalf("Export: %v", err)
}
s2 := newTestStore(t)
changes, err := Apply(s2, exported, ApplyOpts{})
if err != nil {
t.Fatalf("Apply: %v", err)
}
creates := 0
for _, c := range changes {
if c.Action == "create" {
creates++
}
}
if creates != 3 {
t.Fatalf("expected 3 creates, got %d", creates)
}
reexported, err := Export(s2)
if err != nil {
t.Fatalf("re-Export: %v", err)
}
if len(reexported.Alerts) != len(exported.Alerts) {
t.Fatalf("alert count mismatch: %d vs %d", len(reexported.Alerts), len(exported.Alerts))
}
if len(reexported.Monitors) != len(exported.Monitors) {
t.Fatalf("monitor count mismatch: %d vs %d", len(reexported.Monitors), len(exported.Monitors))
}
for i, m := range reexported.Monitors {
if m.Name != exported.Monitors[i].Name {
t.Errorf("monitor %d name mismatch: %q vs %q", i, m.Name, exported.Monitors[i].Name)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package config
type File struct {
Alerts []Alert `yaml:"alerts,omitempty"`
Monitors []Monitor `yaml:"monitors,omitempty"`
}
type Alert struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Settings map[string]string `yaml:"settings"`
}
type Monitor struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
URL string `yaml:"url,omitempty"`
Interval int `yaml:"interval,omitempty"`
Alert string `yaml:"alert,omitempty"`
CheckSSL bool `yaml:"check_ssl,omitempty"`
ExpiryThreshold int `yaml:"expiry_threshold,omitempty"`
MaxRetries int `yaml:"max_retries,omitempty"`
Hostname string `yaml:"hostname,omitempty"`
Port int `yaml:"port,omitempty"`
Timeout int `yaml:"timeout,omitempty"`
Method string `yaml:"method,omitempty"`
Description string `yaml:"description,omitempty"`
AcceptedCodes string `yaml:"accepted_codes,omitempty"`
DNSResolveType string `yaml:"dns_resolve_type,omitempty"`
DNSServer string `yaml:"dns_server,omitempty"`
IgnoreTLS bool `yaml:"ignore_tls,omitempty"`
Paused bool `yaml:"paused,omitempty"`
Regions string `yaml:"regions,omitempty"`
Monitors []Monitor `yaml:"monitors,omitempty"`
}
+88
View File
@@ -0,0 +1,88 @@
package config
import "fmt"
var validMonitorTypes = map[string]bool{
"http": true,
"push": true,
"ping": true,
"port": true,
"dns": true,
"group": true,
}
func Validate(f *File) error {
alertNames := make(map[string]bool, len(f.Alerts))
for _, a := range f.Alerts {
if a.Name == "" {
return fmt.Errorf("alert missing name")
}
if alertNames[a.Name] {
return fmt.Errorf("duplicate alert name %q", a.Name)
}
alertNames[a.Name] = true
if a.Type == "" {
return fmt.Errorf("alert %q: missing type", a.Name)
}
}
monitorNames := make(map[string]bool)
for _, m := range f.Monitors {
if err := validateMonitor(m, monitorNames, false); err != nil {
return err
}
}
return nil
}
func validateMonitor(m Monitor, names map[string]bool, nested bool) error {
if m.Name == "" {
return fmt.Errorf("monitor missing name")
}
if names[m.Name] {
return fmt.Errorf("duplicate monitor name %q", m.Name)
}
names[m.Name] = true
if !validMonitorTypes[m.Type] {
return fmt.Errorf("monitor %q: invalid type %q", m.Name, m.Type)
}
if m.Type == "group" && nested {
return fmt.Errorf("monitor %q: groups cannot be nested inside other groups", m.Name)
}
switch m.Type {
case "http":
if m.URL == "" {
return fmt.Errorf("monitor %q: url is required for type http", m.Name)
}
case "ping":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type ping", m.Name)
}
case "port":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type port", m.Name)
}
if m.Port == 0 {
return fmt.Errorf("monitor %q: port is required for type port", m.Name)
}
case "dns":
if m.Hostname == "" {
return fmt.Errorf("monitor %q: hostname is required for type dns", m.Name)
}
}
if m.Type == "group" {
for _, child := range m.Monitors {
if err := validateMonitor(child, names, true); err != nil {
return err
}
}
} else if len(m.Monitors) > 0 {
return fmt.Errorf("monitor %q: only groups can have nested monitors", m.Name)
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
package config
import (
"strings"
"testing"
)
func TestValidateDuplicateAlertNames(t *testing.T) {
f := &File{
Alerts: []Alert{
{Name: "A", Type: "discord"},
{Name: "A", Type: "slack"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate alert name") {
t.Fatalf("expected duplicate alert error, got %v", err)
}
}
func TestValidateDuplicateMonitorNames(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "M", Type: "http", URL: "https://example.com"},
{Name: "M", Type: "ping", Hostname: "10.0.0.1"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") {
t.Fatalf("expected duplicate monitor error, got %v", err)
}
}
func TestValidateDuplicateNameAcrossGroups(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com"},
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://prod.example.com"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "duplicate monitor name") {
t.Fatalf("expected duplicate name across group, got %v", err)
}
}
func TestValidateNestedGroupReject(t *testing.T) {
f := &File{
Monitors: []Monitor{
{
Name: "Outer", Type: "group",
Monitors: []Monitor{
{Name: "Inner", Type: "group"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "cannot be nested") {
t.Fatalf("expected nested group error, got %v", err)
}
}
func TestValidateRequiredFields(t *testing.T) {
tests := []struct {
name string
monitor Monitor
wantErr string
}{
{"http no url", Monitor{Name: "A", Type: "http"}, "url is required"},
{"ping no hostname", Monitor{Name: "A", Type: "ping"}, "hostname is required"},
{"port no hostname", Monitor{Name: "A", Type: "port", Port: 22}, "hostname is required"},
{"port no port", Monitor{Name: "A", Type: "port", Hostname: "h"}, "port is required"},
{"dns no hostname", Monitor{Name: "A", Type: "dns"}, "hostname is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &File{Monitors: []Monitor{tt.monitor}}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected %q, got %v", tt.wantErr, err)
}
})
}
}
func TestValidateInvalidMonitorType(t *testing.T) {
f := &File{
Monitors: []Monitor{
{Name: "A", Type: "ftp"},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "invalid type") {
t.Fatalf("expected invalid type error, got %v", err)
}
}
func TestValidateNonGroupWithChildren(t *testing.T) {
f := &File{
Monitors: []Monitor{
{
Name: "A", Type: "http", URL: "https://example.com",
Monitors: []Monitor{
{Name: "B", Type: "ping", Hostname: "h"},
},
},
},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "only groups") {
t.Fatalf("expected only-groups error, got %v", err)
}
}
func TestValidateAlertMissingName(t *testing.T) {
f := &File{
Alerts: []Alert{{Type: "discord"}},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "missing name") {
t.Fatalf("expected missing name error, got %v", err)
}
}
func TestValidateAlertMissingType(t *testing.T) {
f := &File{
Alerts: []Alert{{Name: "A"}},
}
err := Validate(f)
if err == nil || !strings.Contains(err.Error(), "missing type") {
t.Fatalf("expected missing type error, got %v", err)
}
}
func TestValidateValidConfig(t *testing.T) {
f := &File{
Alerts: []Alert{
{Name: "Discord", Type: "discord", Settings: map[string]string{"url": "https://example.com"}},
},
Monitors: []Monitor{
{Name: "Web", Type: "http", URL: "https://example.com", Interval: 30, Alert: "Discord"},
{Name: "Ping", Type: "ping", Hostname: "10.0.0.1", Interval: 30},
{Name: "SSH", Type: "port", Hostname: "10.0.0.1", Port: 22, Interval: 60},
{Name: "DNS", Type: "dns", Hostname: "example.com", Interval: 60},
{Name: "Cron", Type: "push", Interval: 300},
{
Name: "Prod", Type: "group",
Monitors: []Monitor{
{Name: "Prod Web", Type: "http", URL: "https://prod.example.com", Interval: 15},
},
},
},
}
if err := Validate(f); err != nil {
t.Fatalf("expected valid config, got %v", err)
}
}
+112
View File
@@ -0,0 +1,112 @@
package metrics
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"sort"
"strings"
)
func Handler(eng *monitor.Engine) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sites := eng.GetAllSites()
sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID })
var b strings.Builder
writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites {
val := 0
if s.Status == "UP" {
val = 1
}
writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val))
}
writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
for _, s := range sites {
writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds())
}
writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.")
for _, s := range sites {
if s.Type != "http" {
continue
}
writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode))
}
writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
for _, s := range sites {
if s.LastCheck.IsZero() {
continue
}
writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
}
writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
for _, s := range sites {
val := 0
if s.Paused {
val = 1
}
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
}
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
for _, s := range sites {
if !s.HasSSL || s.CertExpiry.IsZero() {
continue
}
writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
}
writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.")
for _, s := range sites {
h, ok := eng.GetHistory(s.ID)
if !ok {
continue
}
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks))
}
writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
for _, site := range sites {
probeResults := eng.GetProbeResults(site.ID)
for nodeID, result := range probeResults {
val := 0
if result.IsUp {
val = 1
}
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val))
}
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Write([]byte(b.String()))
}
}
func labels(s models.Site) string {
return fmt.Sprintf(`id="%d",name="%s",type="%s"`, s.ID, escapeLabelValue(s.Name), s.Type)
}
func escapeLabelValue(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
s = strings.ReplaceAll(s, "\n", `\n`)
return s
}
func writeHelp(b *strings.Builder, name, typ, help string) {
fmt.Fprintf(b, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, typ)
}
func writeGauge(b *strings.Builder, name, labels string, val float64) {
fmt.Fprintf(b, "%s{%s} %g\n", name, labels, val)
}
+112
View File
@@ -0,0 +1,112 @@
package metrics
import (
"context"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
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) LoadAllHistory(int) (map[int][]models.CheckRecord, error) {
return nil, nil
}
func (m *mockStore) ExportData() (models.Backup, error) { return models.Backup{}, nil }
func (m *mockStore) ImportData(models.Backup) error { return nil }
func (m *mockStore) GetSiteByName(string) (models.Site, error) { return models.Site{}, nil }
func (m *mockStore) GetAlertByName(string) (models.AlertConfig, error) {
return models.AlertConfig{}, nil
}
func (m *mockStore) AddSiteReturningID(models.Site) (int, error) { return 0, nil }
func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int, error) {
return 0, nil
}
func (m *mockStore) SaveCheckFromNode(int, string, int64, bool) error { return nil }
func (m *mockStore) RegisterNode(models.ProbeNode) error { return nil }
func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.ProbeNode{}, nil }
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
func (m *mockStore) DeleteNode(string) error { return nil }
func (m *mockStore) SaveLog(string) error { return nil }
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
func TestMetricsHandler(t *testing.T) {
ms := &mockStore{
sites: []models.Site{
{ID: 1, Name: "Example", URL: "https://example.com", Type: "http", Interval: 30},
{ID: 2, Name: "DNS Check", Type: "dns", Interval: 60},
},
}
eng := monitor.NewEngine(ms)
ctx, cancel := context.WithCancel(context.Background())
eng.Start(ctx)
time.Sleep(100 * time.Millisecond)
rec := httptest.NewRecorder()
Handler(eng)(rec, httptest.NewRequest("GET", "/metrics", nil))
cancel()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
body := rec.Body.String()
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "text/plain") {
t.Errorf("expected text/plain content type, got %q", ct)
}
expected := []string{
"# HELP upkeep_monitor_up",
"# TYPE upkeep_monitor_up gauge",
`upkeep_monitor_up{id="1",name="Example",type="http"}`,
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP upkeep_monitor_latency_seconds",
"# HELP upkeep_monitor_paused",
"# HELP upkeep_monitor_checks_total",
}
for _, s := range expected {
if !strings.Contains(body, s) {
t.Errorf("missing expected line: %s", s)
}
}
}
func TestEscapeLabelValue(t *testing.T) {
cases := []struct{ in, want string }{
{`simple`, `simple`},
{`has "quotes"`, `has \"quotes\"`},
{"has\nnewline", `has\nnewline`},
{`back\slash`, `back\\slash`},
}
for _, tc := range cases {
got := escapeLabelValue(tc.in)
if got != tc.want {
t.Errorf("escapeLabelValue(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+18 -1
View File
@@ -24,6 +24,8 @@ type Site struct {
DNSResolveType string DNSResolveType string
DNSServer string DNSServer string
IgnoreTLS bool IgnoreTLS bool
Paused bool
Regions string
FailureCount int FailureCount int
Status string Status string
@@ -49,7 +51,22 @@ type User struct {
Role string Role string
} }
// Phase 5: Backup Structure type CheckRecord struct {
SiteID int
NodeID string
LatencyNs int64
IsUp bool
CheckedAt time.Time
}
type ProbeNode struct {
ID string
Name string
Region string
LastSeen time.Time
Version string
}
type Backup struct { type Backup struct {
Sites []Site `json:"sites"` Sites []Site `json:"sites"`
Alerts []AlertConfig `json:"alerts"` Alerts []AlertConfig `json:"alerts"`
+44
View File
@@ -0,0 +1,44 @@
package monitor
import "time"
type AggregationStrategy string
const (
AggAnyDown AggregationStrategy = "any-down"
AggMajorityDown AggregationStrategy = "majority-down"
AggAllDown AggregationStrategy = "all-down"
)
type NodeResult struct {
NodeID string
IsUp bool
LatencyNs int64
CheckedAt time.Time
}
func AggregateStatus(results []NodeResult, strategy AggregationStrategy) (isUp bool, avgLatencyNs int64) {
if len(results) == 0 {
return true, 0
}
upCount := 0
var totalLatency int64
for _, r := range results {
if r.IsUp {
upCount++
}
totalLatency += r.LatencyNs
}
avgLatencyNs = totalLatency / int64(len(results))
switch strategy {
case AggMajorityDown:
isUp = upCount > len(results)/2
case AggAllDown:
isUp = upCount > 0
default:
isUp = upCount == len(results)
}
return
}
+218
View File
@@ -0,0 +1,218 @@
package monitor
import (
"context"
"go-upkeep/internal/models"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
probing "github.com/prometheus-community/pro-bing"
)
type CheckResult struct {
SiteID int
Status string // "UP", "DOWN", "SSL EXP"
StatusCode int
LatencyNs int64
HasSSL bool
CertExpiry time.Time
}
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
switch site.Type {
case "http":
return runHTTPCheck(site, strict, insecure, globalInsecure)
case "ping":
return runPingCheck(site)
case "port":
return runPortCheck(site)
case "dns":
return runDNSCheck(site)
default:
return CheckResult{SiteID: site.ID, Status: "DOWN"}
}
}
func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
method := site.Method
if method == "" {
method = "GET"
}
timeout := siteTimeout(site)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN"}
}
client := strict
if globalInsecure || site.IgnoreTLS {
client = insecure
}
start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start)
result := CheckResult{
SiteID: site.ID,
Status: "UP",
LatencyNs: latency.Nanoseconds(),
}
if err != nil {
result.Status = "DOWN"
return result
}
defer resp.Body.Close()
result.StatusCode = resp.StatusCode
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
result.Status = "DOWN"
}
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
result.HasSSL = true
cert := resp.TLS.PeerCertificates[0]
result.CertExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) {
result.Status = "SSL EXP"
}
}
return result
}
func runPingCheck(site models.Site) CheckResult {
host := site.Hostname
if host == "" {
host = site.URL
}
pinger, err := probing.NewPinger(host)
if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN"}
}
pinger.Count = 1
pinger.Timeout = siteTimeout(site)
pinger.SetPrivileged(false)
start := time.Now()
err = pinger.Run()
latency := time.Since(start)
if err != nil || pinger.Statistics().PacketsRecv == 0 {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
}
stats := pinger.Statistics()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: stats.AvgRtt.Nanoseconds()}
}
func runPortCheck(site models.Site) CheckResult {
host := site.Hostname
if host == "" {
host = site.URL
}
addr := net.JoinHostPort(host, strconv.Itoa(site.Port))
timeout := siteTimeout(site)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, timeout)
latency := time.Since(start)
if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
}
conn.Close()
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
}
func runDNSCheck(site models.Site) CheckResult {
host := site.Hostname
if host == "" {
host = site.URL
}
server := site.DNSServer
if server == "" {
server = "1.1.1.1"
}
if _, _, err := net.SplitHostPort(server); err != nil {
server = net.JoinHostPort(server, "53")
}
qtype := dns.TypeA
switch site.DNSResolveType {
case "AAAA":
qtype = dns.TypeAAAA
case "MX":
qtype = dns.TypeMX
case "CNAME":
qtype = dns.TypeCNAME
case "TXT":
qtype = dns.TypeTXT
case "NS":
qtype = dns.TypeNS
case "SOA":
qtype = dns.TypeSOA
case "SRV":
qtype = dns.TypeSRV
case "PTR":
qtype = dns.TypePTR
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), qtype)
c := new(dns.Client)
c.Timeout = siteTimeout(site)
start := time.Now()
r, _, err := c.Exchange(m, server)
latency := time.Since(start)
if err != nil {
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
}
if r.Rcode != dns.RcodeSuccess {
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds()}
}
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
}
func siteTimeout(site models.Site) time.Duration {
if site.Timeout > 0 {
return time.Duration(site.Timeout) * time.Second
}
return 5 * time.Second
}
func isCodeAccepted(code int, accepted string) bool {
if accepted == "" {
return code >= 200 && code < 300
}
for _, part := range strings.Split(accepted, ",") {
part = strings.TrimSpace(part)
if strings.Contains(part, "-") {
bounds := strings.SplitN(part, "-", 2)
lo, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
hi, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err1 == nil && err2 == nil && code >= lo && code <= hi {
return true
}
} else {
if v, err := strconv.Atoi(part); err == nil && code == v {
return true
}
}
}
return false
}
+41 -22
View File
@@ -1,11 +1,8 @@
package monitor package monitor
import ( import "time"
"sync"
"time"
)
const maxHistoryLen = 30 const maxHistoryLen = 60
type SiteHistory struct { type SiteHistory struct {
Latencies []time.Duration Latencies []time.Duration
@@ -14,19 +11,39 @@ type SiteHistory struct {
UpChecks int UpChecks int
} }
var ( func (e *Engine) InitHistory() {
histories = make(map[int]*SiteHistory) all, err := e.db.LoadAllHistory(maxHistoryLen)
historyMu sync.RWMutex if err != nil {
) e.AddLog("Failed to load check history: " + err.Error())
return
}
e.histMu.Lock()
defer e.histMu.Unlock()
for siteID, records := range all {
h := &SiteHistory{}
for _, r := range records {
h.TotalChecks++
if r.IsUp {
h.UpChecks++
}
h.Latencies = append(h.Latencies, time.Duration(r.LatencyNs))
h.Statuses = append(h.Statuses, r.IsUp)
}
e.histories[siteID] = h
}
if len(all) > 0 {
e.AddLog("Loaded check history from database")
}
}
func RecordCheck(siteID int, latency time.Duration, isUp bool) { func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) {
historyMu.Lock() e.histMu.Lock()
defer historyMu.Unlock() defer e.histMu.Unlock()
h, ok := histories[siteID] h, ok := e.histories[siteID]
if !ok { if !ok {
h = &SiteHistory{} h = &SiteHistory{}
histories[siteID] = h e.histories[siteID] = h
} }
h.TotalChecks++ h.TotalChecks++
@@ -43,12 +60,14 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) {
if len(h.Statuses) > maxHistoryLen { if len(h.Statuses) > maxHistoryLen {
h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:]
} }
go func() { _ = e.db.SaveCheck(siteID, latency.Nanoseconds(), isUp) }()
} }
func GetHistory(siteID int) (SiteHistory, bool) { func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) {
historyMu.RLock() e.histMu.RLock()
defer historyMu.RUnlock() defer e.histMu.RUnlock()
h, ok := histories[siteID] h, ok := e.histories[siteID]
if !ok { if !ok {
return SiteHistory{}, false return SiteHistory{}, false
} }
@@ -63,8 +82,8 @@ func GetHistory(siteID int) (SiteHistory, bool) {
return cp, true return cp, true
} }
func RemoveHistory(siteID int) { func (e *Engine) removeHistory(siteID int) {
historyMu.Lock() e.histMu.Lock()
defer historyMu.Unlock() defer e.histMu.Unlock()
delete(histories, siteID) delete(e.histories, siteID)
} }
+357 -312
View File
@@ -1,285 +1,367 @@
package monitor package monitor
import ( import (
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"go-upkeep/internal/alert" "go-upkeep/internal/alert"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"net"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
"github.com/miekg/dns"
probing "github.com/prometheus-community/pro-bing"
) )
// --- LOGGING --- type Engine struct {
var ( mu sync.RWMutex
LogStore []string liveState map[int]models.Site
LogMutex sync.RWMutex
)
func AddLog(msg string) { logMu sync.RWMutex
LogMutex.Lock() logStore []string
defer LogMutex.Unlock()
ts := time.Now().Format("15:04:05") activeMu sync.RWMutex
entry := fmt.Sprintf("[%s] %s", ts, msg) isActive bool
LogStore = append([]string{entry}, LogStore...)
if len(LogStore) > 100 { histMu sync.RWMutex
LogStore = LogStore[:100] histories map[int]*SiteHistory
tokenIndex map[string]int
probeResultsMu sync.RWMutex
probeResults map[int]map[string]NodeResult
aggStrategy AggregationStrategy
db store.Store
insecureSkipVerify bool
strictClient *http.Client
insecureClient *http.Client
}
func NewEngine(s store.Store) *Engine {
return &Engine{
liveState: make(map[int]models.Site),
histories: make(map[int]*SiteHistory),
tokenIndex: make(map[string]int),
probeResults: make(map[int]map[string]NodeResult),
aggStrategy: AggAnyDown,
isActive: true,
db: s,
strictClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
},
insecureClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
},
} }
} }
func GetLogs() []string { func (e *Engine) SetInsecureSkipVerify(skip bool) {
LogMutex.RLock() e.insecureSkipVerify = skip
defer LogMutex.RUnlock() }
logs := make([]string, len(LogStore))
copy(logs, LogStore) func (e *Engine) AddLog(msg string) {
e.logMu.Lock()
defer e.logMu.Unlock()
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, msg)
e.logStore = append([]string{entry}, e.logStore...)
if len(e.logStore) > 100 {
e.logStore = e.logStore[:100]
}
go func() { _ = e.db.SaveLog(entry) }()
}
func (e *Engine) InitLogs() {
logs, err := e.db.LoadLogs(100)
if err != nil {
return
}
if len(logs) == 0 {
return
}
e.logMu.Lock()
defer e.logMu.Unlock()
e.logStore = logs
}
func (e *Engine) GetLogs() []string {
e.logMu.RLock()
defer e.logMu.RUnlock()
logs := make([]string, len(e.logStore))
copy(logs, e.logStore)
return logs return logs
} }
// --- ENGINE --- func (e *Engine) SetActive(active bool) {
e.activeMu.Lock()
var ( defer e.activeMu.Unlock()
LiveState = make(map[int]models.Site) if e.isActive != active {
Mutex sync.RWMutex e.isActive = active
// Global Switch for HA
isActive = true
activeMutex sync.RWMutex
insecureSkipVerify bool
)
func SetInsecureSkipVerify(skip bool) {
insecureSkipVerify = skip
}
func SetEngineActive(active bool) {
activeMutex.Lock()
defer activeMutex.Unlock()
if isActive != active {
isActive = active
status := "RESUMED (Active)" status := "RESUMED (Active)"
if !active { if !active {
status = "PAUSED (Passive)" status = "PAUSED (Passive)"
} }
AddLog(fmt.Sprintf("Engine %s", status)) e.AddLog(fmt.Sprintf("Engine %s", status))
} }
} }
func IsEngineActive() bool { func (e *Engine) IsActive() bool {
activeMutex.RLock() e.activeMu.RLock()
defer activeMutex.RUnlock() defer e.activeMu.RUnlock()
return isActive return e.isActive
} }
func RecordHeartbeat(token string) bool { func (e *Engine) GetAllSites() []models.Site {
if !IsEngineActive() { e.mu.RLock()
return false defer e.mu.RUnlock()
} // Only Leader accepts Push sites := make([]models.Site, 0, len(e.liveState))
for _, s := range e.liveState {
sites = append(sites, s)
}
return sites
}
Mutex.Lock() func (e *Engine) GetLiveState() map[int]models.Site {
defer Mutex.Unlock() e.mu.RLock()
var targetID int = -1 defer e.mu.RUnlock()
for id, s := range LiveState { cp := make(map[int]models.Site, len(e.liveState))
if s.Type == "push" && s.Token == token { for k, v := range e.liveState {
targetID = id cp[k] = v
break
} }
} return cp
if targetID == -1 { }
func (e *Engine) RecordHeartbeat(token string) bool {
if !e.IsActive() {
return false
}
e.mu.Lock()
defer e.mu.Unlock()
targetID, ok := e.tokenIndex[token]
if !ok {
return false
}
site, exists := e.liveState[targetID]
if !exists {
return false return false
} }
site := LiveState[targetID]
site.LastCheck = time.Now() site.LastCheck = time.Now()
wasDown := site.Status == "DOWN" wasDown := site.Status == "DOWN"
site.Status = "UP" site.Status = "UP"
site.FailureCount = 0 site.FailureCount = 0
site.Latency = 0 site.Latency = 0
LiveState[targetID] = site e.liveState[targetID] = site
if wasDown { if wasDown {
AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name))
triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name))
} }
return true return true
} }
func StartEngine() { func (e *Engine) addToTokenIndex(site models.Site) {
if site.Type == "push" && site.Token != "" {
e.tokenIndex[site.Token] = site.ID
}
}
func (e *Engine) removeFromTokenIndex(id int) {
for token, sid := range e.tokenIndex {
if sid == id {
delete(e.tokenIndex, token)
return
}
}
}
func (e *Engine) Start(ctx context.Context) {
go func() { go func() {
for { for {
s_instance := store.Get() select {
if s_instance == nil { case <-ctx.Done():
time.Sleep(1 * time.Second) return
continue default:
} }
sites := s_instance.GetSites() sites, err := e.db.GetSites()
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
continue
}
for _, s := range sites { for _, s := range sites {
Mutex.RLock() e.mu.RLock()
_, exists := LiveState[s.ID] _, exists := e.liveState[s.ID]
Mutex.RUnlock() e.mu.RUnlock()
if !exists { if !exists {
Mutex.Lock() e.mu.Lock()
s.Status = "PENDING" s.Status = "PENDING"
if s.Type == "push" { if s.Type == "push" {
s.LastCheck = time.Now() s.LastCheck = time.Now()
} }
LiveState[s.ID] = s if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
Mutex.Unlock() if h.Statuses[len(h.Statuses)-1] {
go monitorRoutine(s.ID) s.Status = "UP"
} else {
s.Status = "DOWN"
}
if len(h.Latencies) > 0 {
s.Latency = h.Latencies[len(h.Latencies)-1]
} }
} }
time.Sleep(5 * time.Second) e.liveState[s.ID] = s
e.addToTokenIndex(s)
e.mu.Unlock()
go e.monitorRoutine(ctx, s.ID)
}
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
} }
}() }()
} }
func UpdateSiteConfig(site models.Site) { func (e *Engine) UpdateSiteConfig(site models.Site) {
Mutex.Lock() e.mu.Lock()
defer Mutex.Unlock() defer e.mu.Unlock()
if s, ok := LiveState[site.ID]; ok { if existing, ok := e.liveState[site.ID]; ok {
s.Name = site.Name e.removeFromTokenIndex(site.ID)
s.URL = site.URL site.Status = existing.Status
s.Type = site.Type site.StatusCode = existing.StatusCode
s.Interval = site.Interval site.Latency = existing.Latency
s.AlertID = site.AlertID site.CertExpiry = existing.CertExpiry
s.CheckSSL = site.CheckSSL site.HasSSL = existing.HasSSL
s.ExpiryThreshold = site.ExpiryThreshold site.LastCheck = existing.LastCheck
s.MaxRetries = site.MaxRetries site.SentSSLWarning = existing.SentSSLWarning
s.Hostname = site.Hostname site.FailureCount = existing.FailureCount
s.Port = site.Port e.liveState[site.ID] = site
s.Timeout = site.Timeout e.addToTokenIndex(site)
s.Method = site.Method
s.Description = site.Description
s.ParentID = site.ParentID
s.AcceptedCodes = site.AcceptedCodes
s.DNSResolveType = site.DNSResolveType
s.DNSServer = site.DNSServer
s.IgnoreTLS = site.IgnoreTLS
LiveState[site.ID] = s
} }
} }
func RemoveSite(id int) { func (e *Engine) RemoveSite(id int) {
Mutex.Lock() e.mu.Lock()
delete(LiveState, id) e.removeFromTokenIndex(id)
Mutex.Unlock() delete(e.liveState, id)
RemoveHistory(id) e.mu.Unlock()
e.removeHistory(id)
} }
func monitorRoutine(id int) { func (e *Engine) ToggleSitePause(id int) bool {
checkByID(id) e.mu.Lock()
defer e.mu.Unlock()
site, ok := e.liveState[id]
if !ok {
return false
}
site.Paused = !site.Paused
e.liveState[id] = site
if site.Paused {
e.AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name))
} else {
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name))
}
return site.Paused
}
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
e.checkByID(id)
for { for {
// If paused, just sleep loop to keep goroutine alive but idle select {
if !IsEngineActive() { case <-ctx.Done():
time.Sleep(5 * time.Second) return
default:
}
if !e.IsActive() {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
continue continue
} }
Mutex.RLock() e.mu.RLock()
site, exists := LiveState[id] site, exists := e.liveState[id]
Mutex.RUnlock() e.mu.RUnlock()
if !exists { if !exists {
return return
} }
if site.Paused {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
continue
}
interval := site.Interval interval := site.Interval
if interval < 5 { if interval < 5 {
interval = 5 interval = 5
} }
time.Sleep(time.Duration(interval) * time.Second) select {
checkByID(id) case <-time.After(time.Duration(interval) * time.Second):
case <-ctx.Done():
return
}
e.checkByID(id)
} }
} }
func checkByID(id int) { func (e *Engine) checkByID(id int) {
if !IsEngineActive() { if !e.IsActive() {
return return
} }
Mutex.RLock() e.mu.RLock()
site, exists := LiveState[id] site, exists := e.liveState[id]
Mutex.RUnlock() e.mu.RUnlock()
if !exists { if !exists || site.Paused {
return return
} }
switch site.Type { switch site.Type {
case "http":
checkHTTP(site)
case "push": case "push":
checkPush(site) e.checkPush(site)
case "ping":
checkPing(site)
case "port":
checkPort(site)
case "dns":
checkDNS(site)
case "group": case "group":
// groups don't perform checks e.checkGroup(site)
default:
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
updatedSite.Latency = time.Duration(result.LatencyNs)
updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs))
} }
} }
func checkPush(site models.Site) { func (e *Engine) checkPush(site models.Site) {
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second) deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second)
if time.Now().After(deadline) { if time.Now().After(deadline) {
handleStatusChange(site, "DOWN", 0, 0) e.handleStatusChange(site, "DOWN", 0, 0)
} else { } else if site.Status != "UP" {
if site.Status != "UP" { e.handleStatusChange(site, "UP", 200, 0)
handleStatusChange(site, "UP", 200, 0)
}
} }
} }
func checkHTTP(site models.Site) { func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) {
start := time.Now() if !e.IsActive() {
timeout := time.Duration(site.Timeout) * time.Second
if timeout <= 0 {
timeout = 5 * time.Second
}
skipTLS := insecureSkipVerify || site.IgnoreTLS
client := &http.Client{Timeout: timeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: skipTLS}}}
resp, err := client.Get(site.URL)
latency := time.Since(start)
rawStatus := "UP"
rawCode := 0
var certExpiry time.Time
hasSSL := false
if err != nil {
rawStatus = "DOWN"
} else {
defer resp.Body.Close()
rawCode = resp.StatusCode
if resp.StatusCode >= 400 {
rawStatus = "DOWN"
}
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
hasSSL = true
cert := resp.TLS.PeerCertificates[0]
certExpiry = cert.NotAfter
if time.Now().After(cert.NotAfter) {
rawStatus = "SSL EXP"
}
}
}
updatedSite := site
updatedSite.HasSSL = hasSSL
updatedSite.CertExpiry = certExpiry
updatedSite.Latency = latency
updatedSite.LastCheck = time.Now()
handleStatusChange(updatedSite, rawStatus, rawCode, latency)
}
func handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) {
// Double check we are still leader before alerting
if !IsEngineActive() {
return return
} }
@@ -291,9 +373,9 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti
if newState.FailureCount > site.MaxRetries { if newState.FailureCount > site.MaxRetries {
newState.Status = rawStatus newState.Status = rawStatus
newState.FailureCount = site.MaxRetries + 1 newState.FailureCount = site.MaxRetries + 1
AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name)) e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name))
} else { } else {
AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries)) e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries))
} }
} else if rawStatus == "UP" { } else if rawStatus == "UP" {
newState.FailureCount = 0 newState.FailureCount = 0
@@ -306,20 +388,20 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti
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" {
triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft)) e.triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft))
newState.SentSSLWarning = true newState.SentSSLWarning = true
} else if daysLeft > site.ExpiryThreshold { } else if daysLeft > site.ExpiryThreshold {
newState.SentSSLWarning = false newState.SentSSLWarning = false
} }
} }
Mutex.Lock() e.mu.Lock()
if _, ok := LiveState[site.ID]; ok { if _, ok := e.liveState[site.ID]; ok {
LiveState[site.ID] = newState e.liveState[site.ID] = newState
} }
Mutex.Unlock() e.mu.Unlock()
RecordCheck(site.ID, latency, rawStatus == "UP") e.recordCheck(site.ID, latency, rawStatus == "UP")
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" {
@@ -327,152 +409,115 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti
if site.Type == "push" { if site.Type == "push" {
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name) msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
} }
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" {
triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
} }
} }
func triggerAlert(alertID int, title, message string) { func (e *Engine) triggerAlert(alertID int, title, message string) {
s_instance := store.Get() cfg, err := e.db.GetAlert(alertID)
if s_instance == nil { if err != nil {
return
}
cfg, ok := s_instance.GetAlert(alertID)
if !ok {
return return
} }
provider := alert.GetProvider(cfg) provider := alert.GetProvider(cfg)
if provider != nil { if provider != nil {
go func() { provider.Send(title, message) }() go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = ctx
_ = provider.Send(title, message)
}()
} }
} }
func siteTimeout(site models.Site) time.Duration { func (e *Engine) checkGroup(site models.Site) {
if site.Timeout > 0 { e.mu.RLock()
return time.Duration(site.Timeout) * time.Second status := "UP"
hasChildren := false
allPaused := true
for _, child := range e.liveState {
if child.ParentID != site.ID || child.Type == "group" {
continue
} }
return 5 * time.Second hasChildren = true
if !child.Paused {
allPaused = false
}
if child.Paused {
continue
}
if child.Status == "DOWN" || child.Status == "SSL EXP" {
status = "DOWN"
} else if child.Status == "PENDING" && status != "DOWN" {
status = "PENDING"
}
}
e.mu.RUnlock()
if !hasChildren {
status = "PENDING"
}
e.mu.Lock()
s := e.liveState[site.ID]
s.Status = status
if hasChildren && allPaused {
s.Paused = true
}
e.liveState[site.ID] = s
e.mu.Unlock()
} }
func checkPing(site models.Site) { func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
host := site.Hostname e.aggStrategy = strategy
if host == "" { }
host = site.URL
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool) {
e.probeResultsMu.Lock()
if e.probeResults[siteID] == nil {
e.probeResults[siteID] = make(map[string]NodeResult)
} }
e.probeResults[siteID][nodeID] = NodeResult{
pinger, err := probing.NewPinger(host) NodeID: nodeID,
if err != nil { IsUp: isUp,
handleStatusChange(site, "DOWN", 0, 0) LatencyNs: latencyNs,
AddLog(fmt.Sprintf("Ping '%s' resolve failed: %v", site.Name, err)) CheckedAt: time.Now(),
return
} }
pinger.Count = 1 results := make([]NodeResult, 0, len(e.probeResults[siteID]))
pinger.Timeout = siteTimeout(site) for _, r := range e.probeResults[siteID] {
pinger.SetPrivileged(false) results = append(results, r)
}
e.probeResultsMu.Unlock()
start := time.Now() aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
err = pinger.Run()
latency := time.Since(start)
if err != nil || pinger.Statistics().PacketsRecv == 0 { e.mu.RLock()
updatedSite := site site, exists := e.liveState[siteID]
updatedSite.Latency = latency e.mu.RUnlock()
updatedSite.LastCheck = time.Now() if !exists {
handleStatusChange(updatedSite, "DOWN", 0, latency)
return return
} }
stats := pinger.Statistics() rawStatus := "UP"
updatedSite := site if !aggUp {
updatedSite.Latency = stats.AvgRtt rawStatus = "DOWN"
updatedSite.LastCheck = time.Now()
handleStatusChange(updatedSite, "UP", 0, stats.AvgRtt)
}
func checkPort(site models.Site) {
host := site.Hostname
if host == "" {
host = site.URL
} }
addr := net.JoinHostPort(host, strconv.Itoa(site.Port))
timeout := siteTimeout(site)
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, timeout)
latency := time.Since(start)
updatedSite := site updatedSite := site
updatedSite.Latency = latency updatedSite.Latency = time.Duration(avgLatency)
updatedSite.LastCheck = time.Now() updatedSite.LastCheck = time.Now()
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency))
if err != nil {
handleStatusChange(updatedSite, "DOWN", 0, latency)
return
}
conn.Close()
handleStatusChange(updatedSite, "UP", 0, latency)
} }
func checkDNS(site models.Site) { func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
host := site.Hostname e.probeResultsMu.RLock()
if host == "" { defer e.probeResultsMu.RUnlock()
host = site.URL src := e.probeResults[siteID]
cp := make(map[string]NodeResult, len(src))
for k, v := range src {
cp[k] = v
} }
return cp
server := site.DNSServer
if server == "" {
server = "1.1.1.1"
}
if _, _, err := net.SplitHostPort(server); err != nil {
server = net.JoinHostPort(server, "53")
}
qtype := dns.TypeA
switch site.DNSResolveType {
case "AAAA":
qtype = dns.TypeAAAA
case "MX":
qtype = dns.TypeMX
case "CNAME":
qtype = dns.TypeCNAME
case "TXT":
qtype = dns.TypeTXT
case "NS":
qtype = dns.TypeNS
case "SOA":
qtype = dns.TypeSOA
case "SRV":
qtype = dns.TypeSRV
case "PTR":
qtype = dns.TypePTR
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(host), qtype)
c := new(dns.Client)
c.Timeout = siteTimeout(site)
start := time.Now()
r, rtt, err := c.Exchange(m, server)
_ = rtt
latency := time.Since(start)
updatedSite := site
updatedSite.Latency = latency
updatedSite.LastCheck = time.Now()
if err != nil {
handleStatusChange(updatedSite, "DOWN", 0, latency)
return
}
if r.Rcode != dns.RcodeSuccess {
handleStatusChange(updatedSite, "DOWN", r.Rcode, latency)
return
}
handleStatusChange(updatedSite, "UP", 0, latency)
} }
+273 -68
View File
@@ -4,14 +4,145 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/importer" "go-upkeep/internal/importer"
"go-upkeep/internal/metrics"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor" "go-upkeep/internal/monitor"
"go-upkeep/internal/store" "go-upkeep/internal/store"
"html/template" "html/template"
"log"
"net/http" "net/http"
"sort" "sort"
"strings"
) )
var statusTpl = template.Must(template.New("status").Parse(`
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #1a1b26; color: #a9b1d6; padding: 20px; margin: 0; }
h1 { text-align: center; color: #7aa2f7; margin-bottom: 30px; }
.container { max-width: 800px; margin: 0 auto; }
.card { background: #24283b; padding: 20px; margin-bottom: 15px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.info { display: flex; flex-direction: column; }
.name { font-size: 1.2em; font-weight: bold; color: #c0caf5; margin-bottom: 5px; }
.meta { font-size: 0.85em; color: #565f89; }
.status { font-weight: bold; padding: 6px 12px; border-radius: 6px; min-width: 60px; text-align: center; }
.UP { background: #9ece6a; color: #1a1b26; }
.DOWN { background: #f7768e; color: #1a1b26; }
.PENDING { background: #e0af68; color: #1a1b26; }
.SSL-EXP { background: #e0af68; color: #1a1b26; }
.PAUSED { background: #565f89; color: #c0caf5; }
.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 .s-up { color: #9ece6a; }
.summary .s-down { color: #f7768e; }
.summary .s-paused { color: #565f89; }
.summary .s-total { color: #7aa2f7; }
.stale-bar { text-align: center; font-size: 0.8em; color: #565f89; margin-bottom: 16px; transition: color 0.3s; }
.stale-bar.warn { color: #e0af68; }
.stale-bar.error { color: #f7768e; }
</style>
</head>
<body>
<div class="container">
<h1>{{.Title}}</h1>
<div id="summary" class="summary"></div>
<div id="stale" class="stale-bar"></div>
<div id="cards"></div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
</div>
<script>
var lastUpdate = null;
function esc(s) {
var d = document.createElement('div');
d.appendChild(document.createTextNode(s));
return d.innerHTML;
}
function cssClass(status) {
return status.replace(/\s+/g, '-');
}
function renderSummary(sites) {
var up = 0, down = 0, paused = 0, total = sites.length;
for (var i = 0; i < sites.length; i++) {
if (sites[i].Paused) { paused++; continue; }
if (sites[i].Status === 'UP') up++;
else if (sites[i].Status === 'DOWN') down++;
}
var el = document.getElementById('summary');
var parts = ['<span class="s-total">' + up + '/' + total + ' UP</span>'];
if (down > 0) parts.push('<span class="s-down">' + down + ' DOWN</span>');
if (paused > 0) parts.push('<span class="s-paused">' + paused + ' PAUSED</span>');
el.innerHTML = parts.join('<span style="color:#383838">·</span>');
}
function renderStale() {
var el = document.getElementById('stale');
if (!lastUpdate) { el.textContent = ''; return; }
var ago = Math.round((Date.now() - lastUpdate) / 1000);
el.className = 'stale-bar';
if (ago < 10) {
el.textContent = 'Updated just now';
} else if (ago < 30) {
el.textContent = 'Updated ' + ago + 's ago';
el.className = 'stale-bar warn';
} else {
el.textContent = 'Stale — last update ' + ago + 's ago';
el.className = 'stale-bar error';
}
}
function render(sites) {
var c = document.getElementById('cards');
var html = '';
sites.sort(function(a, b) {
if (a.Status !== b.Status) {
if (a.Status === 'DOWN') return -1;
if (b.Status === 'DOWN') return 1;
}
return a.Name < b.Name ? -1 : a.Name > b.Name ? 1 : 0;
});
renderSummary(sites);
for (var i = 0; i < sites.length; i++) {
var s = sites[i];
var st = s.Paused ? 'PAUSED' : s.Status;
var cls = cssClass(st);
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}) : '—';
html += '<div class="card"><div class="info">' +
'<div class="name">' + esc(s.Name) + '</div>' +
'<div class="meta">' + meta + '</div>' +
'<div class="meta" style="margin-top:4px;">Last Check: ' + lc + '</div>' +
'</div><div class="status ' + cls + '">' + esc(st) + '</div></div>';
}
c.innerHTML = html;
}
function refresh() {
fetch('/status/json')
.then(function(r) { return r.json(); })
.then(function(data) {
var sites = [];
for (var k in data) sites.push(data[k]);
lastUpdate = Date.now();
render(sites);
})
.catch(function() {});
renderStale();
setTimeout(refresh, 5000);
}
setInterval(renderStale, 1000);
refresh();
</script>
</body>
</html>`))
type ServerConfig struct { type ServerConfig struct {
Port int Port int
EnableStatus bool EnableStatus bool
@@ -19,7 +150,7 @@ type ServerConfig struct {
ClusterKey string // Shared Secret for Security ClusterKey string // Shared Secret for Security
} }
func Start(cfg ServerConfig) { func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) {
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.")
} }
@@ -32,7 +163,7 @@ func Start(cfg ServerConfig) {
http.Error(w, "Missing token", 400) http.Error(w, "Missing token", 400)
return return
} }
if monitor.RecordHeartbeat(token) { if eng.RecordHeartbeat(token) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) w.Write([]byte("OK"))
} else { } else {
@@ -56,7 +187,12 @@ func Start(cfg ServerConfig) {
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401)
return return
} }
data := store.Get().ExportData() data, err := s.ExportData()
if err != nil {
log.Printf("Export failed: %v", err)
http.Error(w, "Export failed", 500)
return
}
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
}) })
@@ -75,8 +211,9 @@ func Start(cfg ServerConfig) {
http.Error(w, "Invalid JSON", 400) http.Error(w, "Invalid JSON", 400)
return return
} }
if err := store.Get().ImportData(data); err != nil { if err := s.ImportData(data); err != nil {
http.Error(w, "Import Failed: "+err.Error(), 500) log.Printf("Import failed: %v", err)
http.Error(w, "Import failed", 500)
return return
} }
w.Write([]byte("Import Successful")) w.Write([]byte("Import Successful"))
@@ -94,42 +231,154 @@ func Start(cfg ServerConfig) {
} }
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 {
http.Error(w, "Invalid Kuma JSON: "+err.Error(), 400) log.Printf("Invalid Kuma JSON: %v", err)
http.Error(w, "Invalid Kuma JSON", 400)
return return
} }
backup := importer.ConvertKuma(&kb) backup := importer.ConvertKuma(&kb)
if err := store.Get().ImportData(backup); err != nil { if err := s.ImportData(backup); err != nil {
http.Error(w, "Import Failed: "+err.Error(), 500) log.Printf("Kuma import failed: %v", err)
http.Error(w, "Import failed", 500)
return return
} }
w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version))) w.Write([]byte(fmt.Sprintf("Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)))
}) })
// 6. Status Page // 6. Probe Registration
if cfg.EnableStatus { mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title) }) if r.Method != "POST" {
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { http.Error(w, "POST required", 405)
monitor.Mutex.RLock() return
defer monitor.Mutex.RUnlock() }
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
http.Error(w, "Unauthorized", 401)
return
}
var req struct {
ID string `json:"id"`
Name string `json:"name"`
Region string `json:"region"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
if req.ID == "" {
http.Error(w, "id is required", 400)
return
}
if err := s.RegisterNode(models.ProbeNode{
ID: req.ID, Name: req.Name, Region: req.Region, Version: req.Version,
}); err != nil {
log.Printf("Probe register failed: %v", err)
http.Error(w, "Registration failed", 500)
return
}
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
// 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
http.Error(w, "Unauthorized", 401)
return
}
nodeID := r.URL.Query().Get("node_id")
var nodeRegion string
if nodeID != "" {
if node, err := s.GetNode(nodeID); err == nil {
nodeRegion = node.Region
}
}
sites := eng.GetAllSites()
var assigned []models.Site
for _, site := range sites {
if site.Paused || site.Type == "push" || site.Type == "group" {
continue
}
if site.Regions != "" && nodeRegion != "" {
matched := false
for _, r := range strings.Split(site.Regions, ",") {
if strings.TrimSpace(r) == nodeRegion {
matched = true
break
}
}
if !matched {
continue
}
}
assigned = append(assigned, site)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(monitor.LiveState) json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned})
})
// 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 405)
return
}
if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey {
http.Error(w, "Unauthorized", 401)
return
}
var req struct {
NodeID string `json:"node_id"`
Results []struct {
SiteID int `json:"site_id"`
LatencyNs int64 `json:"latency_ns"`
IsUp bool `json:"is_up"`
} `json:"results"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
if req.NodeID == "" {
http.Error(w, "node_id is required", 400)
return
}
for _, result := range req.Results {
if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil {
log.Printf("Failed to save probe result: %v", err)
}
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp)
}
s.UpdateNodeLastSeen(req.NodeID)
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
// 9. Prometheus Metrics
mux.HandleFunc("/metrics", metrics.Handler(eng))
// 10. Status Page
if cfg.EnableStatus {
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) {
state := eng.GetLiveState()
for id, site := range state {
site.Token = ""
state[id] = site
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(state)
}) })
} }
go func() { go func() {
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
fmt.Printf("HTTP Server listening on %s\n", addr) fmt.Printf("HTTP Server listening on %s\n", addr)
http.ListenAndServe(addr, mux) if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("HTTP server failed: %v", err)
}
}() }()
} }
func renderStatusPage(w http.ResponseWriter, title string) { func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
monitor.Mutex.RLock() sites := eng.GetAllSites()
var sites []models.Site
for _, s := range monitor.LiveState {
sites = append(sites, s)
}
monitor.Mutex.RUnlock()
sort.Slice(sites, func(i, j int) bool { sort.Slice(sites, func(i, j int) bool {
if sites[i].Status != sites[j].Status { if sites[i].Status != sites[j].Status {
@@ -143,53 +392,9 @@ func renderStatusPage(w http.ResponseWriter, title string) {
return sites[i].Name < sites[j].Name return sites[i].Name < sites[j].Name
}) })
const tpl = `
<!DOCTYPE html>
<html>
<head>
<title>{{.Title}}</title>
<meta http-equiv="refresh" content="5">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #1a1b26; color: #a9b1d6; padding: 20px; margin: 0; }
h1 { text-align: center; color: #7aa2f7; margin-bottom: 30px; }
.container { max-width: 800px; margin: 0 auto; }
.card { background: #24283b; padding: 20px; margin-bottom: 15px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
.info { display: flex; flex-direction: column; }
.name { font-size: 1.2em; font-weight: bold; color: #c0caf5; margin-bottom: 5px; }
.meta { font-size: 0.85em; color: #565f89; }
.status { font-weight: bold; padding: 6px 12px; border-radius: 6px; min-width: 60px; text-align: center; }
.UP { background: #9ece6a; color: #1a1b26; }
.DOWN { background: #f7768e; color: #1a1b26; }
.PENDING { background: #e0af68; color: #1a1b26; }
.SSLEXP { background: #e0af68; color: #1a1b26; }
</style>
</head>
<body>
<div class="container">
<h1>{{.Title}}</h1>
{{range .Sites}}
<div class="card">
<div class="info">
<div class="name">{{.Name}}</div>
<div class="meta">{{.Type}} | {{if eq .Type "http"}}{{.URL}}{{else}}Heartbeat Monitor{{end}}</div>
<div class="meta" style="margin-top:4px;">Last Check: {{.LastCheck.Format "15:04:05"}}</div>
</div>
<div class="status {{.Status}}">{{.Status}}</div>
</div>
{{end}}
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
</div>
<script>
setTimeout(function(){ window.location.reload(1); }, 5000);
</script>
</body>
</html>`
t, _ := template.New("status").Parse(tpl)
data := struct { data := struct {
Title string Title string
Sites []models.Site Sites []models.Site
}{Title: title, Sites: sites} }{Title: title, Sites: sites}
t.Execute(w, data) statusTpl.Execute(w, data)
} }
+37
View File
@@ -0,0 +1,37 @@
package store
import "database/sql"
type Dialect interface {
DriverName() string
CreateTablesSQL() []string
MigrationsSQL() []string
BoolFalse() string
ResetSequenceOnEmpty(db *sql.DB, table string)
ImportWipe(tx *sql.Tx)
ImportResetSequences(tx *sql.Tx)
UpsertNodeSQL() string
}
// rewritePlaceholders converts ? markers to $1, $2, etc. for Postgres.
// For SQLite (or any dialect not needing rewrite), returns the input unchanged.
func rewritePlaceholders(query string, dollarStyle bool) string {
if !dollarStyle {
return query
}
buf := make([]byte, 0, len(query)+32)
n := 0
for i := 0; i < len(query); i++ {
if query[i] == '?' {
n++
buf = append(buf, '$')
if n >= 10 {
buf = append(buf, byte('0'+n/10))
}
buf = append(buf, byte('0'+n%10))
} else {
buf = append(buf, query[i])
}
}
return string(buf)
}
+58 -182
View File
@@ -2,67 +2,65 @@ package store
import ( import (
"database/sql" "database/sql"
"encoding/json"
"go-upkeep/internal/models"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
type PostgresStore struct { type PostgresDialect struct{}
ConnStr string
db *sql.DB func NewPostgresStore(connStr string) (*SQLStore, error) {
return NewSQLStore("postgres", connStr, &PostgresDialect{})
} }
func (p *PostgresStore) Init() error { func (d *PostgresDialect) DriverName() string { return "postgres" }
var err error func (d *PostgresDialect) BoolFalse() string { return "FALSE" }
p.db, err = sql.Open("postgres", p.ConnStr)
if err != nil {
return err
}
queries := []string{ func (d *PostgresDialect) CreateTablesSQL() []string {
return []string{
`CREATE TABLE IF NOT EXISTS alerts ( `CREATE TABLE IF NOT EXISTS alerts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT, name TEXT, type TEXT, settings TEXT
type TEXT, )`,
settings TEXT
);`,
`CREATE TABLE IF NOT EXISTS sites ( `CREATE TABLE IF NOT EXISTS sites (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT DEFAULT 'New Monitor', name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http',
url TEXT, token TEXT, interval INTEGER, alert_id INTEGER,
type TEXT DEFAULT 'http', check_ssl BOOLEAN DEFAULT FALSE, threshold INTEGER DEFAULT 7,
token TEXT, max_retries INTEGER DEFAULT 0, hostname TEXT DEFAULT '',
interval INTEGER, port INTEGER DEFAULT 0, timeout INTEGER DEFAULT 0,
alert_id INTEGER, method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
check_ssl BOOLEAN DEFAULT FALSE, parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
threshold INTEGER DEFAULT 7, dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
max_retries INTEGER DEFAULT 0, ignore_tls BOOLEAN DEFAULT FALSE, paused BOOLEAN DEFAULT FALSE
hostname TEXT DEFAULT '', )`,
port INTEGER DEFAULT 0,
timeout INTEGER DEFAULT 0,
method TEXT DEFAULT 'GET',
description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0,
accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '',
dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT FALSE
);`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL, public_key TEXT NOT NULL,
public_key TEXT NOT NULL,
role TEXT DEFAULT 'user' role TEXT DEFAULT 'user'
);`, )`,
} `CREATE TABLE IF NOT EXISTS check_history (
for _, q := range queries { id SERIAL PRIMARY KEY,
if _, err := p.db.Exec(q); err != nil { site_id INTEGER NOT NULL, latency_ns BIGINT,
return err is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW()
} )`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
region TEXT DEFAULT '',
last_seen TIMESTAMP DEFAULT NOW(),
version TEXT DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)`,
} }
}
migrations := []string{ func (d *PostgresDialect) MigrationsSQL() []string {
return []string{
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS hostname TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS port INTEGER DEFAULT 0",
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS timeout INTEGER DEFAULT 0",
@@ -73,148 +71,26 @@ func (p *PostgresStore) Init() error {
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_resolve_type TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS dns_server TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE",
} "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE",
for _, m := range migrations { "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''",
p.db.Exec(m) "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''",
}
return nil
}
// ... [CRUD Methods are identical to Phase 4, keeping them concise here] ...
func (p *PostgresStore) GetSites() []models.Site {
rows, err := p.db.Query("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, FALSE) FROM sites")
if err != nil {
return []models.Site{}
}
defer rows.Close()
var sites []models.Site
for rows.Next() {
var s models.Site
rows.Scan(&s.ID, &s.Name, &s.URL, &s.Type, &s.Token, &s.Interval, &s.AlertID, &s.CheckSSL, &s.ExpiryThreshold, &s.MaxRetries,
&s.Hostname, &s.Port, &s.Timeout, &s.Method, &s.Description, &s.ParentID, &s.AcceptedCodes, &s.DNSResolveType, &s.DNSServer, &s.IgnoreTLS)
sites = append(sites, s)
}
return sites
}
func (p *PostgresStore) AddSite(site models.Site) {
token := ""
if site.Type == "push" {
token = generateToken()
}
p.db.Exec("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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)",
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS)
}
func (p *PostgresStore) UpdateSite(site models.Site) {
var existingToken string
p.db.QueryRow("SELECT token FROM sites WHERE id=$1", site.ID).Scan(&existingToken)
if site.Type == "push" && existingToken == "" {
existingToken = generateToken()
}
p.db.Exec("UPDATE sites SET name=$1, url=$2, type=$3, token=$4, interval=$5, alert_id=$6, check_ssl=$7, threshold=$8, max_retries=$9, hostname=$10, port=$11, timeout=$12, method=$13, description=$14, parent_id=$15, accepted_codes=$16, dns_resolve_type=$17, dns_server=$18, ignore_tls=$19 WHERE id=$20",
site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.ID)
}
func (p *PostgresStore) DeleteSite(id int) { p.db.Exec("DELETE FROM sites WHERE id=$1", id) }
func (p *PostgresStore) GetAllAlerts() []models.AlertConfig {
rows, err := p.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
return []models.AlertConfig{}
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsJSON string
rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
json.Unmarshal([]byte(settingsJSON), &a.Settings)
alerts = append(alerts, a)
}
return alerts
}
func (p *PostgresStore) GetAlert(id int) (models.AlertConfig, bool) {
var a models.AlertConfig
var settingsJSON string
err := p.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = $1", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, false
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, true
}
func (p *PostgresStore) AddAlert(name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
p.db.Exec("INSERT INTO alerts (name, type, settings) VALUES ($1, $2, $3)", name, aType, string(jsonBytes))
}
func (p *PostgresStore) UpdateAlert(id int, name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
p.db.Exec("UPDATE alerts SET name=$1, type=$2, settings=$3 WHERE id=$4", name, aType, string(jsonBytes), id)
}
func (p *PostgresStore) DeleteAlert(id int) { p.db.Exec("DELETE FROM alerts WHERE id=$1", id) }
func (p *PostgresStore) GetAllUsers() []models.User {
rows, err := p.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil {
return []models.User{}
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role)
users = append(users, u)
}
return users
}
func (p *PostgresStore) AddUser(username, publicKey, role string) error {
_, err := p.db.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", username, publicKey, role)
return err
}
func (p *PostgresStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := p.db.Exec("UPDATE users SET username=$1, public_key=$2, role=$3 WHERE id=$4", username, publicKey, role, id)
return err
}
func (p *PostgresStore) DeleteUser(id int) error {
_, err := p.db.Exec("DELETE FROM users WHERE id=$1", id)
return err
}
// --- PHASE 5 ---
func (p *PostgresStore) ExportData() models.Backup {
return models.Backup{
Sites: p.GetSites(),
Alerts: p.GetAllAlerts(),
Users: p.GetAllUsers(),
} }
} }
func (p *PostgresStore) ImportData(data models.Backup) error { func (d *PostgresDialect) UpsertNodeSQL() string {
tx, err := p.db.Begin() return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version"
if err != nil { }
return err
}
func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE")
tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
}
for _, u := range data.Users {
tx.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", u.Username, u.PublicKey, u.Role) func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) {
} tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))")
for _, a := range data.Alerts { tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))")
jsonBytes, _ := json.Marshal(a.Settings) tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))")
tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES ($1, $2, $3, $4)", a.ID, a.Name, a.Type, string(jsonBytes))
}
for _, st := range data.Sites {
tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)",
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS)
}
tx.Exec("SELECT setval('sites_id_seq', (SELECT MAX(id) FROM sites))")
tx.Exec("SELECT setval('alerts_id_seq', (SELECT MAX(id) FROM alerts))")
tx.Exec("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))")
return tx.Commit()
} }
+57 -197
View File
@@ -1,68 +1,66 @@
package store package store
import ( import (
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json"
"go-upkeep/internal/models"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
type SQLiteStore struct { type SQLiteDialect struct{}
DBPath string
db *sql.DB func NewSQLiteStore(path string) (*SQLStore, error) {
return NewSQLStore("sqlite3", path, &SQLiteDialect{})
} }
func (s *SQLiteStore) Init() error { func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
var err error func (d *SQLiteDialect) BoolFalse() string { return "0" }
s.db, err = sql.Open("sqlite3", s.DBPath)
if err != nil {
return err
}
createTables := ` func (d *SQLiteDialect) CreateTablesSQL() []string {
CREATE TABLE IF NOT EXISTS alerts ( return []string{
`CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, name TEXT, type TEXT, settings TEXT
type TEXT, )`,
settings TEXT `CREATE TABLE IF NOT EXISTS sites (
);
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT DEFAULT 'New Monitor', name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http',
url TEXT, token TEXT, interval INTEGER, alert_id INTEGER,
type TEXT DEFAULT 'http', check_ssl BOOLEAN DEFAULT 0, threshold INTEGER DEFAULT 7,
token TEXT, max_retries INTEGER DEFAULT 0, hostname TEXT DEFAULT '',
interval INTEGER, port INTEGER DEFAULT 0, timeout INTEGER DEFAULT 0,
alert_id INTEGER, method TEXT DEFAULT 'GET', description TEXT DEFAULT '',
check_ssl BOOLEAN DEFAULT 0, parent_id INTEGER DEFAULT 0, accepted_codes TEXT DEFAULT '200-299',
threshold INTEGER DEFAULT 7, dns_resolve_type TEXT DEFAULT '', dns_server TEXT DEFAULT '',
max_retries INTEGER DEFAULT 0, ignore_tls BOOLEAN DEFAULT 0, paused BOOLEAN DEFAULT 0
hostname TEXT DEFAULT '', )`,
port INTEGER DEFAULT 0, `CREATE TABLE IF NOT EXISTS users (
timeout INTEGER DEFAULT 0,
method TEXT DEFAULT 'GET',
description TEXT DEFAULT '',
parent_id INTEGER DEFAULT 0,
accepted_codes TEXT DEFAULT '200-299',
dns_resolve_type TEXT DEFAULT '',
dns_server TEXT DEFAULT '',
ignore_tls BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL, username TEXT NOT NULL, public_key TEXT NOT NULL,
public_key TEXT NOT NULL,
role TEXT DEFAULT 'user' role TEXT DEFAULT 'user'
);` )`,
_, err = s.db.Exec(createTables) `CREATE TABLE IF NOT EXISTS check_history (
if err != nil { id INTEGER PRIMARY KEY AUTOINCREMENT,
return err site_id INTEGER NOT NULL, latency_ns INTEGER,
is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_check_history_site ON check_history(site_id, checked_at DESC)`,
`CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
region TEXT DEFAULT '',
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
version TEXT DEFAULT ''
)`,
`CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
} }
}
migrations := []string{ func (d *SQLiteDialect) MigrationsSQL() []string {
return []string{
"ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN hostname TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN port INTEGER DEFAULT 0",
"ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0", "ALTER TABLE sites ADD COLUMN timeout INTEGER DEFAULT 0",
@@ -73,169 +71,31 @@ func (s *SQLiteStore) Init() error {
"ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN dns_resolve_type TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN dns_server TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0",
"ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0",
"ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''",
"ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''",
} }
for _, m := range migrations {
s.db.Exec(m)
}
return nil
} }
func generateToken() string { func (d *SQLiteDialect) UpsertNodeSQL() string {
b := make([]byte, 16) return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)"
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
} }
func (s *SQLiteStore) GetSites() []models.Site { func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
rows, err := s.db.Query("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, 0) FROM sites")
if err != nil {
return []models.Site{}
}
defer rows.Close()
var sites []models.Site
for rows.Next() {
var st models.Site
rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID, &st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout, &st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType, &st.DNSServer, &st.IgnoreTLS)
sites = append(sites, st)
}
return sites
}
func (s *SQLiteStore) AddSite(site models.Site) {
token := ""
if site.Type == "push" {
token = generateToken()
}
s.db.Exec("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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS)
}
func (s *SQLiteStore) UpdateSite(site models.Site) {
var existingToken string
s.db.QueryRow("SELECT token FROM sites WHERE id=?", site.ID).Scan(&existingToken)
if site.Type == "push" && existingToken == "" {
existingToken = generateToken()
}
s.db.Exec("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=? WHERE id=?",
site.Name, site.URL, site.Type, existingToken, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.ID)
}
func (s *SQLiteStore) DeleteSite(id int) {
s.db.Exec("DELETE FROM sites WHERE id=?", id)
var count int var count int
s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count) db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count)
if count == 0 { if count == 0 {
s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table)
}
}
func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig {
rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
return []models.AlertConfig{}
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsJSON string
rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
json.Unmarshal([]byte(settingsJSON), &a.Settings)
alerts = append(alerts, a)
}
return alerts
}
func (s *SQLiteStore) GetAlert(id int) (models.AlertConfig, bool) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = ?", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, false
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, true
}
func (s *SQLiteStore) AddAlert(name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
s.db.Exec("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)", name, aType, string(jsonBytes))
}
func (s *SQLiteStore) UpdateAlert(id int, name, aType string, settings map[string]string) {
jsonBytes, _ := json.Marshal(settings)
s.db.Exec("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?", name, aType, string(jsonBytes), id)
}
func (s *SQLiteStore) DeleteAlert(id int) {
s.db.Exec("DELETE FROM alerts WHERE id=?", id)
var count int
s.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&count)
if count == 0 {
s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
}
}
func (s *SQLiteStore) GetAllUsers() []models.User {
rows, err := s.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil {
return []models.User{}
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role)
users = append(users, u)
}
return users
}
func (s *SQLiteStore) AddUser(username, publicKey, role string) error {
_, err := s.db.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", username, publicKey, role)
return err
}
func (s *SQLiteStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := s.db.Exec("UPDATE users SET username=?, public_key=?, role=? WHERE id=?", username, publicKey, role, id)
return err
}
func (s *SQLiteStore) DeleteUser(id int) error {
_, err := s.db.Exec("DELETE FROM users WHERE id=?", id)
return err
}
// --- PHASE 5 ---
func (s *SQLiteStore) ExportData() models.Backup {
return models.Backup{
Sites: s.GetSites(),
Alerts: s.GetAllAlerts(),
Users: s.GetAllUsers(),
} }
} }
func (s *SQLiteStore) ImportData(data models.Backup) error { func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) {
tx, err := s.db.Begin()
if err != nil {
return err
}
// Wipe Existing
tx.Exec("DELETE FROM sites") tx.Exec("DELETE FROM sites")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'")
tx.Exec("DELETE FROM alerts") tx.Exec("DELETE FROM alerts")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'")
tx.Exec("DELETE FROM users") tx.Exec("DELETE FROM users")
tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'")
// Insert New
for _, u := range data.Users {
tx.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", u.Username, u.PublicKey, u.Role)
}
for _, a := range data.Alerts {
jsonBytes, _ := json.Marshal(a.Settings)
tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)", a.ID, a.Name, a.Type, string(jsonBytes))
}
for _, st := range data.Sites {
tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS)
}
return tx.Commit()
} }
func (d *SQLiteDialect) ImportResetSequences(tx *sql.Tx) {}
+409
View File
@@ -0,0 +1,409 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
)
type SQLStore struct {
db *sql.DB
dialect Dialect
dollar bool
}
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
db, err := sql.Open(driverName, dsn)
if err != nil {
return nil, err
}
_, isDollar := dialect.(*PostgresDialect)
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
}
func (s *SQLStore) q(query string) string {
return rewritePlaceholders(query, s.dollar)
}
func generateToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return hex.EncodeToString(b)
}
func (s *SQLStore) Init() error {
for _, stmt := range s.dialect.CreateTablesSQL() {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
for _, m := range s.dialect.MigrationsSQL() {
s.db.Exec(m)
}
return nil
}
func (s *SQLStore) GetSites() ([]models.Site, error) {
bf := s.dialect.BoolFalse()
query := fmt.Sprintf(
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites",
bf, bf,
)
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var sites []models.Site
for rows.Next() {
var st models.Site
if err := rows.Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
&st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions); err != nil {
return sites, err
}
sites = append(sites, st)
}
return sites, rows.Err()
}
func (s *SQLStore) AddSite(site models.Site) error {
token := ""
if site.Type == "push" {
token = generateToken()
}
_, 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.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
return err
}
func (s *SQLStore) UpdateSite(site models.Site) error {
var existingToken string
s.db.QueryRow(s.q("SELECT token FROM sites WHERE id=?"), site.ID).Scan(&existingToken)
if site.Type == "push" && existingToken == "" {
existingToken = generateToken()
}
_, 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.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions, site.ID)
return err
}
func (s *SQLStore) UpdateSitePaused(id int, paused bool) error {
_, err := s.db.Exec(s.q("UPDATE sites SET paused=? WHERE id=?"), paused, id)
return err
}
func (s *SQLStore) DeleteSite(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM sites WHERE id=?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "sites")
return nil
}
func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
bf := s.dialect.BoolFalse()
query := fmt.Sprintf(
"SELECT id, COALESCE(name, url), url, COALESCE(type, 'http'), COALESCE(token, ''), interval, alert_id, check_ssl, threshold, max_retries, COALESCE(hostname, ''), COALESCE(port, 0), COALESCE(timeout, 0), COALESCE(method, 'GET'), COALESCE(description, ''), COALESCE(parent_id, 0), COALESCE(accepted_codes, '200-299'), COALESCE(dns_resolve_type, ''), COALESCE(dns_server, ''), COALESCE(ignore_tls, %s), COALESCE(paused, %s), COALESCE(regions, '') FROM sites WHERE name = %s",
bf, bf, s.q("?"),
)
var st models.Site
err := s.db.QueryRow(query, name).Scan(&st.ID, &st.Name, &st.URL, &st.Type, &st.Token, &st.Interval, &st.AlertID,
&st.CheckSSL, &st.ExpiryThreshold, &st.MaxRetries, &st.Hostname, &st.Port, &st.Timeout,
&st.Method, &st.Description, &st.ParentID, &st.AcceptedCodes, &st.DNSResolveType,
&st.DNSServer, &st.IgnoreTLS, &st.Paused, &st.Regions)
return st, err
}
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, err
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, nil
}
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
if err := s.AddSite(site); err != nil {
return 0, err
}
created, err := s.GetSiteByName(site.Name)
if err != nil {
return 0, err
}
return created.ID, nil
}
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
if err := s.AddAlert(name, aType, settings); err != nil {
return 0, err
}
created, err := s.GetAlertByName(name)
if err != nil {
return 0, err
}
return created.ID, nil
}
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts")
if err != nil {
return nil, err
}
defer rows.Close()
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsJSON string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil {
return alerts, err
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
alerts = append(alerts, a)
}
return alerts, rows.Err()
}
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig
var settingsJSON string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
if err != nil {
return a, err
}
json.Unmarshal([]byte(settingsJSON), &a.Settings)
return a, nil
}
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes))
return err
}
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id)
return err
}
func (s *SQLStore) DeleteAlert(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM alerts WHERE id=?"), id)
if err != nil {
return err
}
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
return nil
}
func (s *SQLStore) GetAllUsers() ([]models.User, error) {
rows, err := s.db.Query("SELECT id, username, public_key, role FROM users")
if err != nil {
return nil, err
}
defer rows.Close()
var users []models.User
for rows.Next() {
var u models.User
if err := rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role); err != nil {
return users, err
}
users = append(users, u)
}
return users, rows.Err()
}
func (s *SQLStore) AddUser(username, publicKey, role string) error {
_, err := s.db.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), username, publicKey, role)
return err
}
func (s *SQLStore) UpdateUser(id int, username, publicKey, role string) error {
_, err := s.db.Exec(s.q("UPDATE users SET username=?, public_key=?, role=? WHERE id=?"), username, publicKey, role, id)
return err
}
func (s *SQLStore) DeleteUser(id int) error {
_, err := s.db.Exec(s.q("DELETE FROM users WHERE id=?"), id)
return err
}
func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
return s.SaveCheckFromNode(siteID, "", latencyNs, isUp)
}
func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error {
_, err := s.db.Exec(s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
)`), siteID, siteID)
return err
}
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
_, err := s.db.Exec(s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
return err
}
func (s *SQLStore) GetNode(id string) (models.ProbeNode, error) {
var n models.ProbeNode
err := s.db.QueryRow(s.q("SELECT id, name, region, last_seen, version FROM nodes WHERE id = ?"), id).
Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version)
return n, err
}
func (s *SQLStore) GetAllNodes() ([]models.ProbeNode, error) {
rows, err := s.db.Query("SELECT id, name, region, last_seen, version FROM nodes ORDER BY region, name")
if err != nil {
return nil, err
}
defer rows.Close()
var nodes []models.ProbeNode
for rows.Next() {
var n models.ProbeNode
if err := rows.Scan(&n.ID, &n.Name, &n.Region, &n.LastSeen, &n.Version); err != nil {
return nodes, err
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}
func (s *SQLStore) UpdateNodeLastSeen(id string) error {
_, err := s.db.Exec(s.q("UPDATE nodes SET last_seen = CURRENT_TIMESTAMP WHERE id = ?"), id)
return err
}
func (s *SQLStore) DeleteNode(id string) error {
_, err := s.db.Exec(s.q("DELETE FROM nodes WHERE id = ?"), id)
return err
}
func (s *SQLStore) SaveLog(message string) error {
_, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
if err != nil {
return err
}
_, err = s.db.Exec(s.q(`DELETE FROM logs WHERE id NOT IN (
SELECT id FROM logs ORDER BY created_at DESC LIMIT 200
)`))
return err
}
func (s *SQLStore) LoadLogs(limit int) ([]string, error) {
rows, err := s.db.Query(s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []string
for rows.Next() {
var msg string
if err := rows.Scan(&msg); err != nil {
return logs, err
}
logs = append(logs, msg)
}
return logs, rows.Err()
}
func (s *SQLStore) LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) {
result := make(map[int][]models.CheckRecord)
rows, err := s.db.Query(s.q(`
SELECT site_id, latency_ns, is_up FROM (
SELECT site_id, latency_ns, is_up,
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
FROM check_history
) sub WHERE rn <= ?`), limit)
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var r models.CheckRecord
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
return result, err
}
result[r.SiteID] = append(result[r.SiteID], r)
}
for id, records := range result {
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
records[i], records[j] = records[j], records[i]
}
result[id] = records
}
return result, rows.Err()
}
func (s *SQLStore) ExportData() (models.Backup, error) {
sites, err := s.GetSites()
if err != nil {
return models.Backup{}, err
}
alerts, err := s.GetAllAlerts()
if err != nil {
return models.Backup{}, err
}
users, err := s.GetAllUsers()
if err != nil {
return models.Backup{}, err
}
return models.Backup{Sites: sites, Alerts: alerts, Users: users}, nil
}
func (s *SQLStore) ImportData(data models.Backup) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
s.dialect.ImportWipe(tx)
for _, u := range data.Users {
if _, err := tx.Exec(s.q("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)"), u.Username, u.PublicKey, u.Role); err != nil {
return err
}
}
for _, a := range data.Alerts {
jsonBytes, err := json.Marshal(a.Settings)
if err != nil {
return err
}
if _, err := tx.Exec(s.q("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)"), a.ID, a.Name, a.Type, string(jsonBytes)); err != nil {
return err
}
}
for _, st := range data.Sites {
if _, err := tx.Exec(s.q("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries,
st.Hostname, st.Port, st.Timeout, st.Method, st.Description, st.ParentID, st.AcceptedCodes, st.DNSResolveType, st.DNSServer, st.IgnoreTLS, st.Paused, st.Regions); err != nil {
return err
}
}
s.dialect.ImportResetSequences(tx)
return tx.Commit()
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"go-upkeep/internal/models"
"testing"
)
func newTestStore(t *testing.T) *SQLStore {
t.Helper()
s, err := NewSQLiteStore(":memory:")
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if err := s.Init(); err != nil {
t.Fatalf("Init: %v", err)
}
return s
}
func TestSiteCRUD(t *testing.T) {
s := newTestStore(t)
sites, err := s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 0 {
t.Fatalf("expected 0 sites, got %d", len(sites))
}
if err := s.AddSite(models.Site{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
t.Fatalf("AddSite: %v", err)
}
sites, err = s.GetSites()
if err != nil {
t.Fatalf("GetSites: %v", err)
}
if len(sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(sites))
}
if sites[0].Name != "Test" {
t.Errorf("expected name 'Test', got '%s'", sites[0].Name)
}
sites[0].Name = "Updated"
if err := s.UpdateSite(sites[0]); err != nil {
t.Fatalf("UpdateSite: %v", err)
}
sites, _ = s.GetSites()
if sites[0].Name != "Updated" {
t.Errorf("expected name 'Updated', got '%s'", sites[0].Name)
}
if err := s.DeleteSite(sites[0].ID); err != nil {
t.Fatalf("DeleteSite: %v", err)
}
sites, _ = s.GetSites()
if len(sites) != 0 {
t.Fatalf("expected 0 sites after delete, got %d", len(sites))
}
}
func TestAlertCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddAlert("Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
t.Fatalf("AddAlert: %v", err)
}
alerts, err := s.GetAllAlerts()
if err != nil {
t.Fatalf("GetAllAlerts: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Type != "discord" {
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
}
if alerts[0].Settings["url"] != "https://example.com/hook" {
t.Errorf("settings url mismatch")
}
a, err := s.GetAlert(alerts[0].ID)
if err != nil {
t.Fatalf("GetAlert: %v", err)
}
if a.Name != "Discord" {
t.Errorf("expected name 'Discord', got '%s'", a.Name)
}
if err := s.UpdateAlert(a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
t.Fatalf("UpdateAlert: %v", err)
}
a, _ = s.GetAlert(a.ID)
if a.Type != "slack" {
t.Errorf("expected type 'slack', got '%s'", a.Type)
}
if err := s.DeleteAlert(a.ID); err != nil {
t.Fatalf("DeleteAlert: %v", err)
}
alerts, _ = s.GetAllAlerts()
if len(alerts) != 0 {
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
}
}
func TestUserCRUD(t *testing.T) {
s := newTestStore(t)
if err := s.AddUser("admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
t.Fatalf("AddUser: %v", err)
}
users, err := s.GetAllUsers()
if err != nil {
t.Fatalf("GetAllUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if users[0].Username != "admin" {
t.Errorf("expected username 'admin', got '%s'", users[0].Username)
}
if err := s.UpdateUser(users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
users, _ = s.GetAllUsers()
if users[0].Username != "root" {
t.Errorf("expected username 'root', got '%s'", users[0].Username)
}
if err := s.DeleteUser(users[0].ID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
users, _ = s.GetAllUsers()
if len(users) != 0 {
t.Fatalf("expected 0 users after delete, got %d", len(users))
}
}
func TestPushTokenGeneration(t *testing.T) {
s := newTestStore(t)
if err := s.AddSite(models.Site{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
t.Fatalf("AddSite: %v", err)
}
sites, _ := s.GetSites()
if len(sites) != 1 {
t.Fatalf("expected 1 site, got %d", len(sites))
}
if sites[0].Token == "" {
t.Error("expected non-empty token for push monitor")
}
if len(sites[0].Token) != 32 {
t.Errorf("expected 32-char hex token, got %d chars", len(sites[0].Token))
}
}
func TestImportExport(t *testing.T) {
s := newTestStore(t)
s.AddAlert("Test Alert", "webhook", map[string]string{"url": "https://example.com"})
s.AddSite(models.Site{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30})
s.AddUser("user1", "ssh-ed25519 KEY", "user")
backup, err := s.ExportData()
if err != nil {
t.Fatalf("ExportData: %v", err)
}
if len(backup.Sites) != 1 || len(backup.Alerts) != 1 || len(backup.Users) != 1 {
t.Fatalf("export mismatch: %d sites, %d alerts, %d users", len(backup.Sites), len(backup.Alerts), len(backup.Users))
}
s2 := newTestStore(t)
if err := s2.ImportData(backup); err != nil {
t.Fatalf("ImportData: %v", err)
}
sites, _ := s2.GetSites()
alerts, _ := s2.GetAllAlerts()
users, _ := s2.GetAllUsers()
if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 {
t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users))
}
}
func TestCheckHistory(t *testing.T) {
s := newTestStore(t)
if err := s.SaveCheck(1, 5000000, true); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(1, 10000000, false); err != nil {
t.Fatalf("SaveCheck: %v", err)
}
if err := s.SaveCheck(2, 3000000, true); err != nil {
t.Fatalf("SaveCheck site 2: %v", err)
}
history, err := s.LoadAllHistory(10)
if err != nil {
t.Fatalf("LoadAllHistory: %v", err)
}
if len(history[1]) != 2 {
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
}
if len(history[2]) != 1 {
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
}
upCount := 0
for _, r := range history[1] {
if r.IsUp {
upCount++
}
}
if upCount != 1 {
t.Errorf("expected 1 up record for site 1, got %d", upCount)
}
}
+35 -22
View File
@@ -8,35 +8,48 @@ type Store interface {
Init() error Init() error
// Sites // Sites
GetSites() []models.Site GetSites() ([]models.Site, error)
AddSite(site models.Site) AddSite(site models.Site) error
UpdateSite(site models.Site) UpdateSite(site models.Site) error
DeleteSite(id int) UpdateSitePaused(id int, paused bool) error
DeleteSite(id int) error
// Alerts // Alerts
GetAllAlerts() []models.AlertConfig GetAllAlerts() ([]models.AlertConfig, error)
GetAlert(id int) (models.AlertConfig, bool) GetAlert(id int) (models.AlertConfig, error)
AddAlert(name, aType string, settings map[string]string) AddAlert(name, aType string, settings map[string]string) error
UpdateAlert(id int, name, aType string, settings map[string]string) UpdateAlert(id int, name, aType string, settings map[string]string) error
DeleteAlert(id int) DeleteAlert(id int) error
// Declarative config support
GetSiteByName(name string) (models.Site, error)
GetAlertByName(name string) (models.AlertConfig, error)
AddSiteReturningID(site models.Site) (int, error)
AddAlertReturningID(name, aType string, settings map[string]string) (int, error)
// Users // Users
GetAllUsers() []models.User GetAllUsers() ([]models.User, error)
AddUser(username, publicKey, role string) error AddUser(username, publicKey, role string) error
UpdateUser(id int, username, publicKey, role string) error UpdateUser(id int, username, publicKey, role string) error
DeleteUser(id int) error DeleteUser(id int) error
// Phase 5: Backup & Restore // History
ExportData() models.Backup SaveCheck(siteID int, latencyNs int64, isUp bool) error
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
// Nodes
RegisterNode(node models.ProbeNode) error
GetNode(id string) (models.ProbeNode, error)
GetAllNodes() ([]models.ProbeNode, error)
UpdateNodeLastSeen(id string) error
DeleteNode(id string) error
// Logs
SaveLog(message string) error
LoadLogs(limit int) ([]string, error)
// Backup & Restore
ExportData() (models.Backup, error)
ImportData(data models.Backup) error ImportData(data models.Backup) error
} }
var Current Store
func SetGlobal(s Store) {
Current = s
}
func Get() Store {
return Current
}
+146 -63
View File
@@ -2,32 +2,10 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/store"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
alertHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
alertCellStyle = lipgloss.NewStyle().Padding(0, 1)
alertSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
alertBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
alertColWidths = []int{4, 16, 10, 36}
) )
type alertFormData struct { type alertFormData struct {
@@ -45,6 +23,19 @@ type alertFormData struct {
NtfyUser string NtfyUser string
NtfyPass string NtfyPass string
NtfyPri string NtfyPri string
// Telegram
TelegramToken string
TelegramChatID string
// PagerDuty
PagerDutyKey string
PagerDutySeverity string
// Pushover
PushoverToken string
PushoverUser string
// Gotify
GotifyURL string
GotifyToken string
GotifyPriority string
} }
func fmtAlertType(t string) string { func fmtAlertType(t string) string {
@@ -59,6 +50,14 @@ func fmtAlertType(t string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t) return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t)
case "ntfy": case "ntfy":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t) return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t)
case "telegram":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#26A5E4")).Render(t)
case "pagerduty":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#06AC38")).Render(t)
case "pushover":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t)
case "gotify":
return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t)
default: default:
return t return t
} }
@@ -86,6 +85,26 @@ func fmtAlertConfig(alert struct {
return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) return limitStr(fmt.Sprintf("%s/%s", url, topic), 34)
} }
return subtleStyle.Render("—") return subtleStyle.Render("—")
case "telegram":
if id := alert.Settings["chat_id"]; id != "" {
return limitStr(fmt.Sprintf("chat:%s", id), 34)
}
return subtleStyle.Render("—")
case "pagerduty":
if key := alert.Settings["routing_key"]; key != "" {
return limitStr(key, 34)
}
return subtleStyle.Render("—")
case "pushover":
if user := alert.Settings["user"]; user != "" {
return limitStr(fmt.Sprintf("user:%s", user), 34)
}
return subtleStyle.Render("—")
case "gotify":
if url := alert.Settings["url"]; url != "" {
return limitStr(url, 34)
}
return subtleStyle.Render("—")
default: default:
if val, ok := alert.Settings["url"]; ok { if val, ok := alert.Settings["url"]; ok {
return limitStr(val, 34) return limitStr(val, 34)
@@ -99,57 +118,35 @@ func (m Model) viewAlertsTab() string {
return "\n No alert channels configured. Press [n] to add one." return "\n No alert channels configured. Press [n] to add one."
} }
end := m.tableOffset + m.maxTableRows return m.renderTable(
if end > len(m.alerts) { []string{"#", "NAME", "TYPE", "CONFIG"},
end = len(m.alerts) len(m.alerts),
} func(start, end int) [][]string {
selectedVisual := m.cursor - m.tableOffset
var rows [][]string var rows [][]string
for i := m.tableOffset; i < end; i++ { for i := start; i < end; i++ {
alert := m.alerts[i] a := m.alerts[i]
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", alert.ID), fmt.Sprintf("%d", i+1),
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)), m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)),
fmtAlertType(alert.Type), fmtAlertType(a.Type),
fmtAlertConfig(struct { fmtAlertConfig(struct {
Type string Type string
Settings map[string]string Settings map[string]string
}{alert.Type, alert.Settings}), }{a.Type, a.Settings}),
}) })
} }
return rows
t := table.New(). },
Border(lipgloss.RoundedBorder()). nil, nil,
BorderStyle(alertBorderStyle). )
Headers("ID", "NAME", "TYPE", "CONFIG").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
s := alertHeaderStyle
if col < len(alertColWidths) {
s = s.Width(alertColWidths[col])
}
return s
}
s := alertCellStyle
if row == selectedVisual {
s = alertSelectedStyle
}
if col < len(alertColWidths) {
s = s.Width(alertColWidths[col])
}
return s
})
return "\n" + t.Render()
} }
func (m *Model) initAlertHuhForm() tea.Cmd { func (m *Model) initAlertHuhForm() tea.Cmd {
m.alertFormData = &alertFormData{ m.alertFormData = &alertFormData{
AlertType: "discord", AlertType: "discord",
NtfyPri: "3", NtfyPri: "3",
PagerDutySeverity: "critical",
GotifyPriority: "5",
} }
if m.editID > 0 { if m.editID > 0 {
@@ -174,6 +171,19 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
m.alertFormData.NtfyUser = alert.Settings["username"] m.alertFormData.NtfyUser = alert.Settings["username"]
m.alertFormData.NtfyPass = alert.Settings["password"] m.alertFormData.NtfyPass = alert.Settings["password"]
m.alertFormData.NtfyPri = alert.Settings["priority"] m.alertFormData.NtfyPri = alert.Settings["priority"]
case "telegram":
m.alertFormData.TelegramToken = alert.Settings["token"]
m.alertFormData.TelegramChatID = alert.Settings["chat_id"]
case "pagerduty":
m.alertFormData.PagerDutyKey = alert.Settings["routing_key"]
m.alertFormData.PagerDutySeverity = alert.Settings["severity"]
case "pushover":
m.alertFormData.PushoverToken = alert.Settings["token"]
m.alertFormData.PushoverUser = alert.Settings["user"]
case "gotify":
m.alertFormData.GotifyURL = alert.Settings["url"]
m.alertFormData.GotifyToken = alert.Settings["token"]
m.alertFormData.GotifyPriority = alert.Settings["priority"]
} }
break break
} }
@@ -198,6 +208,10 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
huh.NewOption("Webhook", "webhook"), huh.NewOption("Webhook", "webhook"),
huh.NewOption("Email (SMTP)", "email"), huh.NewOption("Email (SMTP)", "email"),
huh.NewOption("Ntfy", "ntfy"), huh.NewOption("Ntfy", "ntfy"),
huh.NewOption("Telegram", "telegram"),
huh.NewOption("PagerDuty", "pagerduty"),
huh.NewOption("Pushover", "pushover"),
huh.NewOption("Gotify", "gotify"),
).Value(&m.alertFormData.AlertType), ).Value(&m.alertFormData.AlertType),
).Title("Alert Config"), ).Title("Alert Config"),
huh.NewGroup( huh.NewGroup(
@@ -205,7 +219,8 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
Placeholder("https://discord.com/api/webhooks/..."). Placeholder("https://discord.com/api/webhooks/...").
Value(&m.alertFormData.WebhookURL), Value(&m.alertFormData.WebhookURL),
).Title("Webhook").WithHideFunc(func() bool { ).Title("Webhook").WithHideFunc(func() bool {
return m.alertFormData.AlertType == "email" || m.alertFormData.AlertType == "ntfy" t := m.alertFormData.AlertType
return t != "discord" && t != "slack" && t != "webhook"
}), }),
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("Ntfy Server URL"). huh.NewInput().Title("Ntfy Server URL").
@@ -253,6 +268,57 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
).Title("Email Settings").WithHideFunc(func() bool { ).Title("Email Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "email" return m.alertFormData.AlertType != "email"
}), }),
huh.NewGroup(
huh.NewInput().Title("Bot Token").
Placeholder("123456:ABC-DEF1234...").
Value(&m.alertFormData.TelegramToken),
huh.NewInput().Title("Chat ID").
Placeholder("-1001234567890").
Value(&m.alertFormData.TelegramChatID),
).Title("Telegram Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "telegram"
}),
huh.NewGroup(
huh.NewInput().Title("Routing Key").
Placeholder("your-integration-routing-key").
Value(&m.alertFormData.PagerDutyKey),
huh.NewSelect[string]().Title("Severity").
Options(
huh.NewOption("Critical", "critical"),
huh.NewOption("Error", "error"),
huh.NewOption("Warning", "warning"),
huh.NewOption("Info", "info"),
).Value(&m.alertFormData.PagerDutySeverity),
).Title("PagerDuty Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "pagerduty"
}),
huh.NewGroup(
huh.NewInput().Title("App Token").
Placeholder("your-pushover-app-token").
Value(&m.alertFormData.PushoverToken),
huh.NewInput().Title("User Key").
Placeholder("your-pushover-user-key").
Value(&m.alertFormData.PushoverUser),
).Title("Pushover Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "pushover"
}),
huh.NewGroup(
huh.NewInput().Title("Server URL").
Placeholder("https://gotify.example.com").
Value(&m.alertFormData.GotifyURL),
huh.NewInput().Title("App Token").
Placeholder("your-gotify-app-token").
Value(&m.alertFormData.GotifyToken),
huh.NewSelect[string]().Title("Priority").
Options(
huh.NewOption("Min (0)", "0"),
huh.NewOption("Low (2)", "2"),
huh.NewOption("Normal (5)", "5"),
huh.NewOption("High (8)", "8"),
).Value(&m.alertFormData.GotifyPriority),
).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify"
}),
).WithTheme(huh.ThemeDracula()) ).WithTheme(huh.ThemeDracula())
return m.huhForm.Init() return m.huhForm.Init()
@@ -276,14 +342,31 @@ func (m *Model) submitAlertForm() {
settings["priority"] = d.NtfyPri settings["priority"] = d.NtfyPri
settings["username"] = d.NtfyUser settings["username"] = d.NtfyUser
settings["password"] = d.NtfyPass settings["password"] = d.NtfyPass
case "telegram":
settings["token"] = d.TelegramToken
settings["chat_id"] = d.TelegramChatID
case "pagerduty":
settings["routing_key"] = d.PagerDutyKey
settings["severity"] = d.PagerDutySeverity
case "pushover":
settings["token"] = d.PushoverToken
settings["user"] = d.PushoverUser
case "gotify":
settings["url"] = d.GotifyURL
settings["token"] = d.GotifyToken
settings["priority"] = d.GotifyPriority
default: default:
settings["url"] = d.WebhookURL settings["url"] = d.WebhookURL
} }
if m.editID > 0 { if m.editID > 0 {
store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings) if err := m.store.UpdateAlert(m.editID, d.Name, d.AlertType, settings); err != nil {
m.engine.AddLog("Update alert failed: " + err.Error())
}
} else { } else {
store.Get().AddAlert(d.Name, d.AlertType, settings) if err := m.store.AddAlert(d.Name, d.AlertType, settings); err != nil {
m.engine.AddLog("Add alert failed: " + err.Error())
}
} }
m.state = stateDashboard m.state = stateDashboard
} }
+96
View File
@@ -0,0 +1,96 @@
package tui
import (
"fmt"
"go-upkeep/internal/models"
"strings"
"time"
)
func (m Model) viewNodesTab() string {
if len(m.nodes) == 0 {
return "\n No probe nodes connected."
}
colWidths := []int{0, 12, 20, 10, 8}
return m.renderTable(
[]string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"},
len(m.nodes),
func(start, end int) [][]string {
var rows [][]string
for i := start; i < end; i++ {
node := m.nodes[i]
name := limitStr(node.Name, 20)
if name == "" {
name = node.ID
}
region := node.Region
if region == "" {
region = subtleStyle.Render("—")
}
lastSeen := fmtNodeLastSeen(node.LastSeen)
version := node.Version
if version == "" {
version = subtleStyle.Render("—")
}
status := fmtNodeStatus(node.LastSeen)
rows = append(rows, []string{name, region, lastSeen, version, status})
}
return rows
},
colWidths,
nil,
)
}
func fmtNodeStatus(lastSeen time.Time) string {
if lastSeen.IsZero() {
return subtleStyle.Render("UNKNOWN")
}
ago := time.Since(lastSeen)
if ago < 60*time.Second {
return specialStyle.Render("ONLINE")
}
if ago < 5*time.Minute {
return warnStyle.Render("STALE")
}
return dangerStyle.Render("OFFLINE")
}
func fmtNodeLastSeen(t time.Time) string {
if t.IsZero() {
return subtleStyle.Render("never")
}
ago := time.Since(t)
if ago < time.Minute {
return fmt.Sprintf("%ds ago", int(ago.Seconds()))
}
if ago < time.Hour {
return fmt.Sprintf("%dm ago", int(ago.Minutes()))
}
return fmt.Sprintf("%dh ago", int(ago.Hours()))
}
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
}
+341 -88
View File
@@ -3,8 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
@@ -13,35 +11,43 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
) )
var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
var ( func typeIcon(siteType string, collapsed bool) string {
siteHeaderStyle = lipgloss.NewStyle(). switch siteType {
Foreground(lipgloss.Color("#7D56F4")). case "http":
Bold(true). return "→"
Padding(0, 1) case "push":
return "↓"
case "ping":
return "↔"
case "port":
return "⊡"
case "dns":
return "◆"
case "group":
if collapsed {
return ""
}
return ""
default:
return "·"
}
}
siteCellStyle = lipgloss.NewStyle().Padding(0, 1) var siteGroupStyle = lipgloss.NewStyle().
siteSelectedStyle = lipgloss.NewStyle().
Padding(0, 1). Padding(0, 1).
Bold(true). Bold(true).
Foreground(lipgloss.Color("#ffffff")). Foreground(lipgloss.Color("#7D56F4"))
Background(lipgloss.Color("#3b3b5c"))
siteBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
siteColWidths = []int{4, 14, 6, 8, 9, 8, 20, 10, 6}
)
type siteFormData struct { type siteFormData struct {
Name string Name string
SiteType string SiteType string
URL string URL string
Method string
AcceptedCodes string
Interval string Interval string
AlertID string AlertID string
CheckSSL bool CheckSSL bool
@@ -52,6 +58,8 @@ type siteFormData struct {
Timeout string Timeout string
Description string Description string
IgnoreTLS bool IgnoreTLS bool
GroupID string
Regions string
} }
func latencySparkline(latencies []time.Duration, width int) string { func latencySparkline(latencies []time.Duration, width int) string {
@@ -75,6 +83,9 @@ func latencySparkline(latencies []time.Duration, width int) string {
} }
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
spread := maxL - minL spread := maxL - minL
for _, l := range samples { for _, l := range samples {
idx := 0 idx := 0
@@ -94,10 +105,6 @@ func latencySparkline(latencies []time.Duration, width int) string {
sb.WriteString(dangerStyle.Render(ch)) sb.WriteString(dangerStyle.Render(ch))
} }
} }
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
return sb.String() return sb.String()
} }
@@ -112,6 +119,9 @@ func heartbeatSparkline(statuses []bool, width int) string {
} }
var sb strings.Builder var sb strings.Builder
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
for _, up := range samples { for _, up := range samples {
if up { if up {
sb.WriteString(specialStyle.Render("▁")) sb.WriteString(specialStyle.Render("▁"))
@@ -119,10 +129,6 @@ func heartbeatSparkline(statuses []bool, width int) string {
sb.WriteString(dangerStyle.Render("█")) sb.WriteString(dangerStyle.Render("█"))
} }
} }
if remaining := width - len(samples); remaining > 0 {
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
}
return sb.String() return sb.String()
} }
@@ -146,11 +152,17 @@ func fmtLatency(d time.Duration) string {
return dangerStyle.Render(s) return dangerStyle.Render(s)
} }
func fmtUptime(total, up int) string { func fmtUptime(statuses []bool) string {
if total == 0 { if len(statuses) == 0 {
return subtleStyle.Render("—") return subtleStyle.Render("—")
} }
pct := float64(up) / float64(total) * 100 up := 0
for _, s := range statuses {
if s {
up++
}
}
pct := float64(up) / float64(len(statuses)) * 100
s := fmt.Sprintf("%.1f%%", pct) s := fmt.Sprintf("%.1f%%", pct)
if pct >= 99 { if pct >= 99 {
return specialStyle.Render(s) return specialStyle.Render(s)
@@ -195,7 +207,10 @@ func fmtRetries(site models.Site) string {
return s return s
} }
func fmtStatus(status string) string { func fmtStatus(status string, paused bool) string {
if paused {
return warnStyle.Render("PAUSED")
}
switch { switch {
case status == "DOWN" || status == "SSL EXP": case status == "DOWN" || status == "SSL EXP":
return dangerStyle.Render(status) return dangerStyle.Render(status)
@@ -206,25 +221,87 @@ func fmtStatus(status string) string {
} }
} }
func (m Model) dynamicWidths() (nameW, sparkW int) {
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
overhead := 30 // cell padding + borders
avail := m.termWidth - 6 - fixed - overhead
if avail < 30 {
avail = 30
}
nameW = avail / 2
sparkW = avail - nameW - 2 // -2 for spark column padding
if nameW < 13 {
nameW = 13
}
if nameW > 40 {
nameW = 40
}
if sparkW < 10 {
sparkW = 10
}
if sparkW > 60 {
sparkW = 60
}
return
}
func (m Model) viewSitesTab() string { func (m Model) viewSitesTab() string {
const sparkWidth = 20
if len(m.sites) == 0 { if len(m.sites) == 0 {
return "\n No sites configured. Press [n] to add one." welcome := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 3).
Render(
titleStyle.Render("Go-Upkeep") + "\n\n" +
"No monitors configured yet.\n\n" +
subtleStyle.Render("[n] Add your first monitor"),
)
return "\n" + welcome
} }
end := m.tableOffset + m.maxTableRows nameW, sparkWidth := m.dynamicWidths()
if end > len(m.sites) { colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9}
end = len(m.sites)
}
selectedVisual := m.cursor - m.tableOffset
var groupRows map[int]bool
return m.renderTable(
[]string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"},
len(m.sites),
func(start, end int) [][]string {
groupRows = make(map[int]bool)
var rows [][]string var rows [][]string
for i := m.tableOffset; i < end; i++ { for i := start; i < end; i++ {
site := m.sites[i] site := m.sites[i]
hist, _ := monitor.GetHistory(site.ID)
if site.Type == "group" {
groupRows[i-start] = true
icon := typeIcon("group", m.collapsed[site.ID])
rows = append(rows, []string{
strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
"group",
fmtStatus(site.Status, site.Paused),
subtleStyle.Render("—"),
subtleStyle.Render("—"),
subtleStyle.Render(strings.Repeat("·", sparkWidth)),
subtleStyle.Render("-"),
subtleStyle.Render("—"),
})
continue
}
name := site.Name
if site.ParentID > 0 {
prefix := "├"
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
prefix = "└"
}
name = prefix + " " + limitStr(name, nameW-2)
} else {
name = limitStr(name, nameW)
}
hist, _ := m.engine.GetHistory(site.ID)
var spark string var spark string
if site.Type == "push" { if site.Type == "push" {
spark = heartbeatSparkline(hist.Statuses, sparkWidth) spark = heartbeatSparkline(hist.Statuses, sparkWidth)
@@ -233,52 +310,41 @@ func (m Model) viewSitesTab() string {
} }
rows = append(rows, []string{ rows = append(rows, []string{
strconv.Itoa(site.ID), strconv.Itoa(i + 1),
m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)), m.zones.Mark(fmt.Sprintf("site-%d", i), name),
site.Type, typeIcon(site.Type, false) + " " + site.Type,
fmtStatus(site.Status), fmtStatus(site.Status, site.Paused),
fmtLatency(site.Latency), fmtLatency(site.Latency),
fmtUptime(hist.TotalChecks, hist.UpChecks), fmtUptime(hist.Statuses),
spark, spark,
fmtSSL(site), fmtSSL(site),
fmtRetries(site), fmtRetries(site),
}) })
} }
return rows
t := table.New(). },
Border(lipgloss.RoundedBorder()). colWidths,
BorderStyle(siteBorderStyle). func(row, col int) *lipgloss.Style {
Headers("ID", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). if groupRows[row] {
Rows(rows...). s := siteGroupStyle
StyleFunc(func(row, col int) lipgloss.Style { return &s
if row == table.HeaderRow {
s := siteHeaderStyle
if col < len(siteColWidths) {
s = s.Width(siteColWidths[col])
} }
return s return nil
} },
s := siteCellStyle )
if row == selectedVisual {
s = siteSelectedStyle
}
if col < len(siteColWidths) {
s = s.Width(siteColWidths[col])
}
return s
})
return "\n" + t.Render()
} }
func (m *Model) initSiteHuhForm() tea.Cmd { func (m *Model) initSiteHuhForm() tea.Cmd {
m.siteFormData = &siteFormData{ m.siteFormData = &siteFormData{
SiteType: "http", SiteType: "http",
Method: "GET",
AcceptedCodes: "200-299",
Interval: "60", Interval: "60",
Threshold: "7", Threshold: "7",
Retries: "0", Retries: "0",
Timeout: "5", Timeout: "5",
Port: "0", Port: "0",
GroupID: "0",
} }
if m.editID > 0 { if m.editID > 0 {
@@ -297,14 +363,18 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
m.siteFormData.Timeout = strconv.Itoa(site.Timeout) m.siteFormData.Timeout = strconv.Itoa(site.Timeout)
m.siteFormData.Description = site.Description m.siteFormData.Description = site.Description
m.siteFormData.IgnoreTLS = site.IgnoreTLS m.siteFormData.IgnoreTLS = site.IgnoreTLS
m.siteFormData.GroupID = strconv.Itoa(site.ParentID)
m.siteFormData.Method = site.Method
m.siteFormData.AcceptedCodes = site.AcceptedCodes
m.siteFormData.Regions = site.Regions
break break
} }
} }
} }
alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} alertOpts := []huh.Option[string]{huh.NewOption("None", "0")}
if store.Get() != nil { if alerts, err := m.store.GetAllAlerts(); err == nil {
for _, a := range store.Get().GetAllAlerts() { for _, a := range alerts {
alertOpts = append(alertOpts, huh.NewOption( alertOpts = append(alertOpts, huh.NewOption(
fmt.Sprintf("%s (%s)", a.Name, a.Type), fmt.Sprintf("%s (%s)", a.Name, a.Type),
strconv.Itoa(a.ID), strconv.Itoa(a.ID),
@@ -312,6 +382,13 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
} }
} }
groupOpts := []huh.Option[string]{huh.NewOption("None", "0")}
for _, s := range m.sites {
if s.Type == "group" && s.ID != m.editID {
groupOpts = append(groupOpts, huh.NewOption(s.Name, strconv.Itoa(s.ID)))
}
}
m.huhForm = huh.NewForm( m.huhForm = huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput().Title("Monitor Name"). huh.NewInput().Title("Monitor Name").
@@ -332,12 +409,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
huh.NewOption("DNS", "dns"), huh.NewOption("DNS", "dns"),
huh.NewOption("Group", "group"), huh.NewOption("Group", "group"),
).Value(&m.siteFormData.SiteType), ).Value(&m.siteFormData.SiteType),
huh.NewSelect[string]().Title("Alert Channel").
Options(alertOpts...).
Value(&m.siteFormData.AlertID),
).Title("Monitor Settings"),
huh.NewGroup(
huh.NewInput().Title("URL"). huh.NewInput().Title("URL").
Placeholder("https://example.com"). Placeholder("https://example.com").
Description("Required for HTTP monitors"). Description("Required for HTTP monitors").
Value(&m.siteFormData.URL). Value(&m.siteFormData.URL).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType == "push" { if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
return nil return nil
} }
if s == "" { if s == "" {
@@ -357,12 +439,23 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
}), }),
huh.NewInput().Title("Check Interval (seconds)"). huh.NewInput().Title("Check Interval (seconds)").
Placeholder("60"). Placeholder("60").
Value(&m.siteFormData.Interval), Value(&m.siteFormData.Interval).
huh.NewSelect[string]().Title("Alert Channel"). Validate(func(s string) error {
Options(alertOpts...). if m.siteFormData.SiteType == "group" {
Value(&m.siteFormData.AlertID), return nil
).Title("Monitor Settings"), }
huh.NewGroup( v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 5 {
return fmt.Errorf("minimum interval is 5 seconds")
}
return nil
}),
huh.NewSelect[string]().Title("Parent Group").
Options(groupOpts...).
Value(&m.siteFormData.GroupID),
huh.NewInput().Title("Hostname / IP"). huh.NewInput().Title("Hostname / IP").
Placeholder("10.0.0.1"). Placeholder("10.0.0.1").
Description("Target for ping/port/DNS monitors"). Description("Target for ping/port/DNS monitors").
@@ -370,26 +463,95 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
huh.NewInput().Title("Port"). huh.NewInput().Title("Port").
Placeholder("0"). Placeholder("0").
Description("Target port for TCP port monitors"). Description("Target port for TCP port monitors").
Value(&m.siteFormData.Port), Value(&m.siteFormData.Port).
Validate(func(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 0 || v > 65535 {
return fmt.Errorf("port must be 0-65535")
}
return nil
}),
huh.NewInput().Title("Timeout (seconds)"). huh.NewInput().Title("Timeout (seconds)").
Placeholder("5"). Placeholder("5").
Value(&m.siteFormData.Timeout), Value(&m.siteFormData.Timeout).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 || v > 300 {
return fmt.Errorf("timeout must be 1-300 seconds")
}
return nil
}),
huh.NewInput().Title("Description"). huh.NewInput().Title("Description").
Placeholder("Optional description"). Placeholder("Optional description").
Value(&m.siteFormData.Description), Value(&m.siteFormData.Description),
).Title("Connection"), huh.NewInput().Title("Probe Regions").
Placeholder("us-east, eu-west (empty = all)").
Description("Comma-separated regions for distributed probing").
Value(&m.siteFormData.Regions),
).Title("Connection").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
huh.NewGroup(
huh.NewSelect[string]().Title("HTTP Method").
Options(
huh.NewOption("GET", "GET"),
huh.NewOption("POST", "POST"),
huh.NewOption("PUT", "PUT"),
huh.NewOption("PATCH", "PATCH"),
huh.NewOption("DELETE", "DELETE"),
huh.NewOption("HEAD", "HEAD"),
huh.NewOption("OPTIONS", "OPTIONS"),
).Value(&m.siteFormData.Method),
huh.NewInput().Title("Accepted Status Codes").
Placeholder("200-299").
Description("Ranges (200-299) and singles (301) separated by commas").
Value(&m.siteFormData.AcceptedCodes),
).Title("HTTP Settings").WithHideFunc(func() bool {
return m.siteFormData.SiteType != "http"
}),
huh.NewGroup( huh.NewGroup(
huh.NewConfirm().Title("Monitor SSL Certificate?"). huh.NewConfirm().Title("Monitor SSL Certificate?").
Value(&m.siteFormData.CheckSSL), Value(&m.siteFormData.CheckSSL),
huh.NewInput().Title("SSL Warning Threshold (days)"). huh.NewInput().Title("SSL Warning Threshold (days)").
Placeholder("7"). Placeholder("7").
Value(&m.siteFormData.Threshold), Value(&m.siteFormData.Threshold).
Validate(func(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 {
return fmt.Errorf("threshold must be at least 1 day")
}
return nil
}),
huh.NewInput().Title("Max Retries Before Alert"). huh.NewInput().Title("Max Retries Before Alert").
Placeholder("0"). Placeholder("0").
Value(&m.siteFormData.Retries), Value(&m.siteFormData.Retries).
Validate(func(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 0 {
return fmt.Errorf("retries cannot be negative")
}
return nil
}),
huh.NewConfirm().Title("Ignore TLS Errors?"). huh.NewConfirm().Title("Ignore TLS Errors?").
Value(&m.siteFormData.IgnoreTLS), Value(&m.siteFormData.IgnoreTLS),
).Title("Advanced"), ).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
).WithTheme(huh.ThemeDracula()) ).WithTheme(huh.ThemeDracula())
return m.huhForm.Init() return m.huhForm.Init()
@@ -403,6 +565,7 @@ func (m *Model) submitSiteForm() {
retries, _ := strconv.Atoi(d.Retries) retries, _ := strconv.Atoi(d.Retries)
port, _ := strconv.Atoi(d.Port) port, _ := strconv.Atoi(d.Port)
timeout, _ := strconv.Atoi(d.Timeout) timeout, _ := strconv.Atoi(d.Timeout)
groupID, _ := strconv.Atoi(d.GroupID)
if interval < 1 { if interval < 1 {
interval = 60 interval = 60
} }
@@ -425,13 +588,103 @@ func (m *Model) submitSiteForm() {
Timeout: timeout, Timeout: timeout,
Description: d.Description, Description: d.Description,
IgnoreTLS: d.IgnoreTLS, IgnoreTLS: d.IgnoreTLS,
ParentID: groupID,
Method: d.Method,
AcceptedCodes: d.AcceptedCodes,
Regions: d.Regions,
} }
if m.editID > 0 { if m.editID > 0 {
store.Get().UpdateSite(site) if err := m.store.UpdateSite(site); err != nil {
monitor.UpdateSiteConfig(site) m.engine.AddLog("Update site failed: " + err.Error())
}
m.engine.UpdateSiteConfig(site)
} else { } else {
store.Get().AddSite(site) if err := m.store.AddSite(site); err != nil {
m.engine.AddLog("Add site failed: " + err.Error())
}
} }
m.state = stateDashboard m.state = stateDashboard
} }
func (m Model) viewDetailPanel() string {
if m.cursor >= len(m.sites) {
return ""
}
site := m.sites[m.cursor]
hist, _ := m.engine.GetHistory(site.ID)
var b strings.Builder
title := titleStyle.Render(fmt.Sprintf(" %s", site.Name))
b.WriteString(title + "\n\n")
row := func(label, value string) {
b.WriteString(fmt.Sprintf(" %-16s %s\n", subtleStyle.Render(label), value))
}
row("Status", fmtStatus(site.Status, site.Paused))
row("Type", site.Type)
if site.URL != "" {
row("URL", site.URL)
}
if site.Hostname != "" {
row("Host", site.Hostname)
}
if site.Port > 0 {
row("Port", strconv.Itoa(site.Port))
}
row("Interval", fmt.Sprintf("%ds", site.Interval))
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
row("Latency", fmtLatency(site.Latency))
row("Uptime", fmtUptime(hist.Statuses))
if site.Type == "http" {
row("Method", site.Method)
row("Codes", site.AcceptedCodes)
row("SSL", fmtSSL(site))
if site.IgnoreTLS {
row("TLS Verify", dangerStyle.Render("disabled"))
}
}
if site.MaxRetries > 0 {
row("Retries", fmtRetries(site))
}
if site.Regions != "" {
row("Regions", site.Regions)
}
if site.Description != "" {
row("Description", site.Description)
}
if !site.LastCheck.IsZero() {
row("Last Check", site.LastCheck.Format("15:04:05"))
}
probeResults := m.engine.GetProbeResults(site.ID)
if len(probeResults) > 0 {
b.WriteString("\n" + subtleStyle.Render(" PROBE RESULTS") + "\n")
for nodeID, result := range probeResults {
status := specialStyle.Render("UP")
if !result.IsUp {
status = dangerStyle.Render("DN")
}
latency := time.Duration(result.LatencyNs).Milliseconds()
ago := time.Since(result.CheckedAt).Truncate(time.Second)
b.WriteString(fmt.Sprintf(" %-14s %s %dms %s ago\n", nodeID, status, latency, ago))
}
}
b.WriteString("\n")
const sparkWidth = 40
if site.Type == "push" {
b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth))
} else {
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
}
b.WriteString("\n\n")
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [q] Quit"))
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
+16 -59
View File
@@ -2,32 +2,9 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/store"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
userHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
userCellStyle = lipgloss.NewStyle().Padding(0, 1)
userSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
userBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
userColWidths = []int{4, 16, 10, 44}
) )
type userFormData struct { type userFormData struct {
@@ -55,48 +32,24 @@ func (m Model) viewUsersTab() string {
return "\n No users configured. Press [n] to add one." return "\n No users configured. Press [n] to add one."
} }
end := m.tableOffset + m.maxTableRows return m.renderTable(
if end > len(m.users) { []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"},
end = len(m.users) len(m.users),
} func(start, end int) [][]string {
selectedVisual := m.cursor - m.tableOffset
var rows [][]string var rows [][]string
for i := m.tableOffset; i < end; i++ { for i := start; i < end; i++ {
u := m.users[i] u := m.users[i]
rows = append(rows, []string{ rows = append(rows, []string{
fmt.Sprintf("%d", u.ID), fmt.Sprintf("%d", i+1),
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
fmtRole(u.Role), fmtRole(u.Role),
fmtKey(u.PublicKey), fmtKey(u.PublicKey),
}) })
} }
return rows
t := table.New(). },
Border(lipgloss.RoundedBorder()). nil, nil,
BorderStyle(userBorderStyle). )
Headers("ID", "USERNAME", "ROLE", "PUBLIC KEY").
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
s := userHeaderStyle
if col < len(userColWidths) {
s = s.Width(userColWidths[col])
}
return s
}
s := userCellStyle
if row == selectedVisual {
s = userSelectedStyle
}
if col < len(userColWidths) {
s = s.Width(userColWidths[col])
}
return s
})
return "\n" + t.Render()
} }
func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) initUserHuhForm() tea.Cmd {
@@ -149,9 +102,13 @@ func (m *Model) initUserHuhForm() tea.Cmd {
func (m *Model) submitUserForm() { func (m *Model) submitUserForm() {
d := m.userFormData d := m.userFormData
if m.editID > 0 { if m.editID > 0 {
store.Get().UpdateUser(m.editID, d.Username, d.PublicKey, d.Role) if err := m.store.UpdateUser(m.editID, d.Username, d.PublicKey, d.Role); err != nil {
m.engine.AddLog("Update user failed: " + err.Error())
}
} else { } else {
store.Get().AddUser(d.Username, d.PublicKey, d.Role) if err := m.store.AddUser(d.Username, d.PublicKey, d.Role); err != nil {
m.engine.AddLog("Add user failed: " + err.Error())
}
} }
m.state = stateUsers m.state = stateUsers
} }
+75
View File
@@ -0,0 +1,75 @@
package tui
import (
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
)
var (
tableHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
)
type StyleOverride func(row, col int) *lipgloss.Style
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
if items == 0 {
return ""
}
end := m.tableOffset + m.maxTableRows
if end > items {
end = items
}
selectedVisual := m.cursor - m.tableOffset
rows := buildRows(m.tableOffset, end)
tableWidth := m.termWidth - 6
if tableWidth < 40 {
tableWidth = 40
}
t := table.New().
Border(lipgloss.RoundedBorder()).
BorderStyle(tableBorderStyle).
Width(tableWidth).
Headers(headers...).
Rows(rows...).
StyleFunc(func(row, col int) lipgloss.Style {
if row == table.HeaderRow {
return tableHeaderStyle
}
if styleOverride != nil {
if s := styleOverride(row, col); s != nil {
if col < len(colWidths) && colWidths[col] > 0 {
return s.Width(colWidths[col])
}
return *s
}
}
base := tableCellStyle
if row == selectedVisual {
base = tableSelectedStyle
}
if col < len(colWidths) && colWidths[col] > 0 {
base = base.Width(colWidths[col])
}
return base
})
return "\n" + t.Render()
}
+339 -46
View File
@@ -19,7 +19,7 @@ import (
) )
var ( var (
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}) subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"})
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"})
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"})
@@ -37,9 +37,11 @@ const (
stateDashboard sessionState = iota stateDashboard sessionState = iota
stateLogs stateLogs
stateUsers stateUsers
stateDetail
stateFormSite stateFormSite
stateFormAlert stateFormAlert
stateFormUser stateFormUser
stateConfirmDelete
) )
type Model struct { type Model struct {
@@ -48,6 +50,8 @@ type Model struct {
cursor int cursor int
tableOffset int tableOffset int
maxTableRows int maxTableRows int
termWidth int
termHeight int
editID int editID int
editToken string editToken string
@@ -60,6 +64,14 @@ type Model struct {
isAdmin bool isAdmin bool
zones *zone.Manager zones *zone.Manager
deleteID int
deleteName string
deleteTab int
collapsed map[int]bool
store store.Store
engine *monitor.Engine
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
pulsePos float64 pulsePos float64
@@ -69,9 +81,13 @@ type Model struct {
sites []models.Site sites []models.Site
alerts []models.AlertConfig alerts []models.AlertConfig
users []models.User users []models.User
nodes []models.ProbeNode
filterMode bool
filterText string
} }
func InitialModel(isAdmin bool) Model { func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
vpLogs := viewport.New(100, 20) vpLogs := viewport.New(100, 20)
vpLogs.SetContent("Waiting for logs...") vpLogs.SetContent("Waiting for logs...")
z := zone.New() z := zone.New()
@@ -81,8 +97,11 @@ func InitialModel(isAdmin bool) Model {
logViewport: vpLogs, logViewport: vpLogs,
maxTableRows: 5, maxTableRows: 5,
isAdmin: isAdmin, isAdmin: isAdmin,
store: s,
engine: eng,
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: make(map[int]bool),
} }
} }
@@ -93,6 +112,45 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
if m.state == stateConfirmDelete {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "y", "Y":
switch m.deleteTab {
case 0:
if err := m.store.DeleteSite(m.deleteID); err != nil {
m.engine.AddLog("Delete site failed: " + err.Error())
}
m.engine.RemoveSite(m.deleteID)
m.adjustCursor(len(m.sites) - 1)
case 1:
if err := m.store.DeleteAlert(m.deleteID); err != nil {
m.engine.AddLog("Delete alert failed: " + err.Error())
}
m.adjustCursor(len(m.alerts) - 1)
case 3:
if err := m.store.DeleteUser(m.deleteID); err != nil {
m.engine.AddLog("Delete user failed: " + err.Error())
}
m.adjustCursor(len(m.users) - 1)
}
m.refreshData()
m.state = stateDashboard
if m.deleteTab == 4 {
m.state = stateUsers
}
case "n", "N", "esc":
m.state = stateDashboard
if m.deleteTab == 4 {
m.state = stateUsers
}
case "ctrl+c":
return m, tea.Quit
}
}
return m, nil
}
// 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 {
if keyMsg, ok := msg.(tea.KeyMsg); ok { if keyMsg, ok := msg.(tea.KeyMsg); ok {
@@ -102,7 +160,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 == 3 { if m.currentTab == 4 {
m.state = stateUsers m.state = stateUsers
} }
return m, nil return m, nil
@@ -126,6 +184,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.termWidth = msg.Width
m.termHeight = msg.Height
m.maxTableRows = msg.Height - 12 m.maxTableRows = msg.Height - 12
if m.maxTableRows < 1 { if m.maxTableRows < 1 {
m.maxTableRows = 1 m.maxTableRows = 1
@@ -159,6 +219,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.currentTab == 1 { if m.currentTab == 1 {
listLen = len(m.alerts) listLen = len(m.alerts)
} else if m.currentTab == 3 { } else if m.currentTab == 3 {
listLen = len(m.nodes)
} else if m.currentTab == 4 {
listLen = len(m.users) listLen = len(m.users)
} }
if msg.Button == tea.MouseButtonWheelUp { if msg.Button == tea.MouseButtonWheelUp {
@@ -188,11 +250,54 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.ClearScreen return m, tea.ClearScreen
} }
if m.filterMode {
switch msg.String() {
case "esc":
m.filterMode = false
m.filterText = ""
m.cursor = 0
m.tableOffset = 0
m.refreshData()
case "enter":
m.filterMode = false
case "backspace":
if len(m.filterText) > 0 {
m.filterText = m.filterText[:len(m.filterText)-1]
m.cursor = 0
m.tableOffset = 0
m.refreshData()
}
case "ctrl+c":
return m, tea.Quit
default:
if len(msg.String()) == 1 {
m.filterText += msg.String()
m.cursor = 0
m.tableOffset = 0
m.refreshData()
}
}
return m, nil
}
switch m.state { switch m.state {
case stateDetail:
switch msg.String() {
case "i", "esc":
m.state = stateDashboard
case "q":
return m, tea.Quit
}
return m, nil
case stateDashboard, stateLogs, stateUsers: case stateDashboard, stateLogs, stateUsers:
switch msg.String() { switch msg.String() {
case "q": case "q":
return m, tea.Quit return m, tea.Quit
case "/":
if m.currentTab == 0 {
m.filterMode = true
return m, nil
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "pgup", "pgdown": case "pgup", "pgdown":
@@ -218,6 +323,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
max = len(m.alerts) - 1 max = len(m.alerts) - 1
} }
if m.currentTab == 3 { if m.currentTab == 3 {
max = len(m.nodes) - 1
}
if m.currentTab == 4 {
max = len(m.users) - 1 max = len(m.users) - 1
} }
if m.cursor < max { if m.cursor < max {
@@ -236,7 +344,7 @@ 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 == 3 && m.isAdmin { } else if m.currentTab == 4 && m.isAdmin {
m.state = stateFormUser m.state = stateFormUser
return m, m.initUserHuhForm() return m, m.initUserHuhForm()
} }
@@ -250,37 +358,58 @@ 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 == 3 && m.isAdmin && len(m.users) > 0 { } else if m.currentTab == 4 && 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()
} }
case "d", "backspace": case " ":
if m.currentTab == 1 && len(m.alerts) > 0 { if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
store.Get().DeleteAlert(m.alerts[m.cursor].ID) gid := m.sites[m.cursor].ID
m.adjustCursor(len(m.alerts) - 1) m.collapsed[gid] = !m.collapsed[gid]
} else if m.currentTab == 0 && len(m.sites) > 0 {
id := m.sites[m.cursor].ID
store.Get().DeleteSite(id)
monitor.RemoveSite(id)
m.adjustCursor(len(m.sites) - 1)
} else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 {
store.Get().DeleteUser(m.users[m.cursor].ID)
m.adjustCursor(len(m.users) - 1)
}
m.refreshData() m.refreshData()
} }
case "p":
if m.currentTab == 0 && len(m.sites) > 0 {
site := m.sites[m.cursor]
m.engine.ToggleSitePause(site.ID)
site.Paused = !site.Paused
_ = m.store.UpdateSitePaused(site.ID, site.Paused)
m.refreshData()
}
case "i":
if m.currentTab == 0 && len(m.sites) > 0 {
m.state = stateDetail
}
case "d", "backspace":
if m.currentTab == 0 && len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
m.deleteName = m.sites[m.cursor].Name
m.deleteTab = 0
m.state = stateConfirmDelete
} else if m.currentTab == 1 && len(m.alerts) > 0 {
m.deleteID = m.alerts[m.cursor].ID
m.deleteName = m.alerts[m.cursor].Name
m.deleteTab = 1
m.state = stateConfirmDelete
} else if m.currentTab == 4 && m.isAdmin && len(m.users) > 0 {
m.deleteID = m.users[m.cursor].ID
m.deleteName = m.users[m.cursor].Username
m.deleteTab = 4
m.state = stateConfirmDelete
}
}
} }
} }
return m, nil return m, nil
} }
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
maxTabs := 3 tabCount := 4
if !m.isAdmin { if m.isAdmin {
maxTabs = 2 tabCount = 5
} }
for i := 0; i <= maxTabs; 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) {
m.switchTab(i) m.switchTab(i)
return m, nil return m, nil
@@ -313,7 +442,7 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
} }
} }
if m.currentTab == 3 { if m.currentTab == 4 {
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)
@@ -330,9 +459,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 := 2 maxTabs := 3
if m.isAdmin { if m.isAdmin {
maxTabs = 3 maxTabs = 4
} }
if idx > maxTabs { if idx > maxTabs {
idx = 0 idx = 0
@@ -343,7 +472,7 @@ func (m *Model) switchTab(idx int) {
switch idx { switch idx {
case 2: case 2:
m.state = stateLogs m.state = stateLogs
case 3: case 4:
m.state = stateUsers m.state = stateUsers
default: default:
m.state = stateDashboard m.state = stateDashboard
@@ -363,27 +492,78 @@ func (m *Model) adjustCursor(newLen int) {
} }
func (m *Model) refreshData() { func (m *Model) refreshData() {
monitor.Mutex.RLock() allSites := m.engine.GetAllSites()
var sites []models.Site
for _, s := range monitor.LiveState { var groups, ungrouped []models.Site
sites = append(sites, s) children := make(map[int][]models.Site)
for _, s := range allSites {
if s.Type == "group" {
groups = append(groups, s)
} else if s.ParentID > 0 {
children[s.ParentID] = append(children[s.ParentID], s)
} else {
ungrouped = append(ungrouped, s)
}
}
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
for pid := range children {
c := children[pid]
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
children[pid] = c
}
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
var ordered []models.Site
for _, g := range groups {
ordered = append(ordered, g)
if !m.collapsed[g.ID] {
ordered = append(ordered, children[g.ID]...)
}
}
ordered = append(ordered, ungrouped...)
if m.filterText != "" {
var filtered []models.Site
needle := strings.ToLower(m.filterText)
for _, s := range ordered {
if strings.Contains(strings.ToLower(s.Name), needle) {
filtered = append(filtered, s)
}
}
ordered = filtered
}
m.sites = ordered
if alerts, err := m.store.GetAllAlerts(); err == nil {
m.alerts = alerts
} }
monitor.Mutex.RUnlock()
sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID })
m.sites = sites
if store.Get() != nil {
m.alerts = store.Get().GetAllAlerts()
if m.isAdmin { if m.isAdmin {
m.users = store.Get().GetAllUsers() if users, err := m.store.GetAllUsers(); err == nil {
m.users = users
} }
} }
m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) if nodes, err := m.store.GetAllNodes(); err == nil {
m.nodes = nodes
}
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
listLen := len(m.sites)
if m.currentTab == 1 {
listLen = len(m.alerts)
} else if m.currentTab == 3 {
listLen = len(m.nodes)
} else if m.currentTab == 4 {
listLen = len(m.users)
}
if listLen > 0 && m.cursor >= listLen {
m.cursor = listLen - 1
}
if m.cursor < m.tableOffset {
m.tableOffset = m.cursor
}
} }
func (m *Model) submitForm() { func (m *Model) submitForm() {
if store.Get() == nil {
return
}
switch m.state { switch m.state {
case stateFormSite: case stateFormSite:
if m.siteFormData != nil { if m.siteFormData != nil {
@@ -406,12 +586,39 @@ func (m Model) pulseIndicator() string {
if brightness > 255 { if brightness > 255 {
brightness = 255 brightness = 255
} }
color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) hasDown := false
for _, s := range m.sites {
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
hasDown = true
break
}
}
var color string
if hasDown {
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
} else {
color = fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame])
} }
func (m Model) View() string { func (m Model) View() string {
switch m.state { switch m.state {
case stateConfirmDelete:
kind := "monitor"
if m.deleteTab == 1 {
kind = "alert"
} else if m.deleteTab == 4 {
kind = "user"
}
msg := dangerStyle.Render(fmt.Sprintf("Delete %s \"%s\"?", kind, m.deleteName))
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#F25D94")).
Padding(1, 3).
Render(msg + "\n\n" + hint)
return lipgloss.NewStyle().Padding(2, 4).Render(box)
case stateFormSite, stateFormAlert, stateFormUser: case stateFormSite, stateFormAlert, stateFormUser:
if m.huhForm != nil { if m.huhForm != nil {
title := "" title := ""
@@ -437,13 +644,45 @@ func (m Model) View() string {
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer)
} }
return "" return ""
case stateDetail:
return m.viewDetailPanel()
default: default:
return m.zones.Scan(m.viewDashboard()) return m.zones.Scan(m.viewDashboard())
} }
} }
func (m Model) viewDashboard() string { func (m Model) viewDashboard() string {
tabs := []string{"Sites", "Alerts", "Logs"} downCount := 0
for _, s := range m.sites {
if !s.Paused && (s.Status == "DOWN" || s.Status == "SSL EXP") {
downCount++
}
}
offlineNodes := 0
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
offlineNodes++
}
}
var sitesLabel string
if downCount > 0 {
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
} else if len(m.sites) > 0 {
sitesLabel = fmt.Sprintf("Sites (%d)", len(m.sites))
} else {
sitesLabel = "Sites"
}
var nodesLabel string
if offlineNodes > 0 {
nodesLabel = fmt.Sprintf("Nodes (%d!)", offlineNodes)
} else if len(m.nodes) > 0 {
nodesLabel = fmt.Sprintf("Nodes (%d)", len(m.nodes))
} else {
nodesLabel = "Nodes"
}
tabs := []string{sitesLabel, "Alerts", "Logs", nodesLabel}
if m.isAdmin { if m.isAdmin {
tabs = append(tabs, "Users") tabs = append(tabs, "Users")
} }
@@ -471,16 +710,70 @@ func (m Model) viewDashboard() string {
case 2: case 2:
content = m.viewLogsTab() content = m.viewLogsTab()
case 3: case 3:
content = m.viewNodesTab()
case 4:
if m.isAdmin { if m.isAdmin {
content = m.viewUsersTab() content = m.viewUsersTab()
} }
} }
footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") upCount := len(m.sites) - downCount
if m.currentTab == 3 { var upStr string
footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") if downCount > 0 {
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites)))
} else {
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, len(m.sites)))
}
statusParts := []string{upStr}
if len(m.nodes) > 0 {
online := 0
for _, n := range m.nodes {
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
online++
}
}
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
}
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
var footer string
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│")
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
} else {
var keys string
switch m.currentTab {
case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
case 4:
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
default:
keys = "[Tab]Switch [q]Quit"
}
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
if m.filterText != "" && m.currentTab == 0 {
footer = "\n" + subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + subtleStyle.Render(keys)
}
}
s := lipgloss.NewStyle().Padding(1, 2)
if m.termHeight > 0 {
s = s.MaxHeight(m.termHeight)
}
return s.Render(header + "\n" + content + "\n" + footer)
}
func siteOrder(s models.Site) int {
if s.Paused {
return 3
}
switch s.Status {
case "DOWN", "SSL EXP":
return 0
case "PENDING":
return 2
default:
return 1
} }
return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n" + content + "\n" + footer)
} }
func limitStr(text string, max int) string { func limitStr(text string, max int) string {