diff --git a/.gitignore b/.gitignore index e1f6a8e..1fd01d6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ tmp # Old repo /go-upkeep/ -*.local.json \ No newline at end of file +*.local.json +*.local.md \ No newline at end of file diff --git a/README.md b/README.md index e7424e1..291c2de 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,63 @@ # 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) -* ๐Ÿณ **Docker Hub:** [rdgames1000/go-upkeep](https://hub.docker.com/r/rdgames1000/go-upkeep) +## What it does ---- +- **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 -go mod tidy 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 -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 -Create `docker-compose.yml`: +Apply a config file: + +```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 services: monitor: - image: rdgames1000/go-upkeep:latest - container_name: go-upkeep + build: . restart: unless-stopped - stdin_open: true # Required for initial setup console + stdin_open: true tty: true ports: - - "23234:23234" # SSH - - "8080:8080" # HTTP (Status Page & Push) + - "23234:23234" + - "8080:8080" volumes: - ./data:/data - ./ssh_keys:/app/.ssh @@ -62,28 +65,26 @@ services: - UPKEEP_DB_TYPE=sqlite - UPKEEP_DB_DSN=/data/upkeep.db - UPKEEP_STATUS_ENABLED=true - - UPKEEP_CLUSTER_SECRET=ChangeMeToSomethingSecure + - UPKEEP_CLUSTER_SECRET=change-me ``` -### 2. Initial Setup (Identity Management) -**Important:** V2 stores SSH keys in the database. You must create the first user manually via the console. +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. -1. Start the stack: `docker compose up -d` -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**. +## Environment variables -### 3. Usage -Connect using your standard SSH client: -```bash -ssh -p 23234 your-server-ip -``` +| Variable | Default | What it does | +|---|---|---| +| `UPKEEP_PORT` | `23234` | SSH server port | +| `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 License. \ No newline at end of file +MIT โ€” see [LICENSE](LICENSE). diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go index 0ae9cb0..adec38f 100644 --- a/cmd/goupkeep/main.go +++ b/cmd/goupkeep/main.go @@ -1,9 +1,11 @@ package main import ( + "context" "flag" "fmt" "go-upkeep/internal/cluster" + "go-upkeep/internal/config" "go-upkeep/internal/importer" "go-upkeep/internal/models" "go-upkeep/internal/monitor" @@ -26,6 +28,102 @@ import ( func main() { 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 dbType := "sqlite" dbDSN := "upkeep.db" @@ -58,7 +156,6 @@ func main() { if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { statusTitle = v } - if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { clusterMode = v } @@ -68,32 +165,71 @@ func main() { if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); 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) + } + + fmt.Printf("Cluster: Running as PROBE (node=%s, region=%s)\n", nodeID, nodeRegion) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + 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 } - port := flag.Int("port", portVal, "SSH Port") - flagDBType := flag.String("db-type", dbType, "Database type") - flagDSN := flag.String("dsn", dbDSN, "Database DSN") - demo := flag.Bool("demo", false, "Seed demo data") - importKuma := flag.String("import-kuma", "", "Import Uptime Kuma backup JSON file") - flag.Parse() + 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 dbErr error if *flagDBType == "postgres" { - s = &store.PostgresStore{ConnStr: *flagDSN} + s, dbErr = store.NewPostgresStore(*flagDSN) fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) } else { - s = &store.SQLiteStore{DBPath: *flagDSN} + s, dbErr = store.NewSQLiteStore(*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 { - fmt.Printf("Database Init Error: %v\n", err) + fmt.Printf("Database init error: %v\n", err) os.Exit(1) } - store.SetGlobal(s) - if *demo { 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) } - 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{ Port: httpPort, EnableStatus: enableStatus, Title: statusTitle, ClusterKey: clusterKey, - }) + }, s, eng) - cluster.Start(cluster.Config{ + cluster.Start(ctx, cluster.Config{ Mode: clusterMode, PeerURL: clusterPeer, SharedKey: clusterKey, - }) + }, eng) - startSSHServer(*port) + startSSHServer(*port, s, eng) 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 { fmt.Printf("Error: %v\n", err) } @@ -141,18 +290,19 @@ func main() { <-done fmt.Println("Shutting down...") } + cancel() } -func startSSHServer(port int) { +func startSSHServer(port int, db store.Store, eng *monitor.Engine) { s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { - return isKeyAllowed(key) + return isKeyAllowed(db, key) }), wish.WithMiddleware( 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) 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) { - if existing := s.GetSites(); len(existing) > 0 { + existing, _ := s.GetSites() + if len(existing) > 0 { return } fmt.Println("Seeding demo data...") @@ -177,7 +332,7 @@ func seedDemoData(s store.Store) { "from": "oncall@example.com", "to": "team@example.com", }) - alerts := s.GetAllAlerts() + alerts, _ := s.GetAllAlerts() alertID := 0 if len(alerts) > 0 { 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}) } -func isKeyAllowed(incomingKey ssh.PublicKey) bool { - users := store.Get().GetAllUsers() +func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { + users, err := db.GetAllUsers() + if err != nil { + return false + } for _, u := range users { allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) if err != nil { diff --git a/docker-compose.probe.yml b/docker-compose.probe.yml new file mode 100644 index 0000000..791811f --- /dev/null +++ b/docker-compose.probe.yml @@ -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 diff --git a/docs/config-as-code.md b/docs/config-as-code.md new file mode 100644 index 0000000..1c9e8a1 --- /dev/null +++ b/docs/config-as-code.md @@ -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 +``` diff --git a/go.mod b/go.mod index 7011fb1..38cc730 100644 --- a/go.mod +++ b/go.mod @@ -57,4 +57,5 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 88b63c7..17c3eca 100644 --- a/go.sum +++ b/go.sum @@ -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/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/alert/alert.go b/internal/alert/alert.go index 71b7570..c60013f 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -7,6 +7,7 @@ import ( "go-upkeep/internal/models" "net/http" "net/smtp" + "strconv" "strings" "time" ) @@ -17,15 +18,95 @@ type Provider interface { 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 { switch cfg.Type { case "discord": - return &DiscordProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: discordPayload} case "slack": - return &SlackProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: slackPayload} case "webhook": - // Generic Webhook - return &WebhookProvider{URL: cfg.Settings["url"]} + return &HTTPProvider{URL: cfg.Settings["url"], Payload: webhookPayload} case "email": port := "25" if p, ok := cfg.Settings["port"]; ok { @@ -51,58 +132,40 @@ func GetProvider(cfg models.AlertConfig) Provider { Username: cfg.Settings["username"], 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: 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 { Host, Port, User, Pass, To, From string } @@ -139,6 +202,9 @@ func (n *NtfyProvider) Send(title, message string) error { if err != nil { return err } - resp.Body.Close() + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("ntfy returned HTTP %d", resp.StatusCode) + } return nil } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go new file mode 100644 index 0000000..35e1c8d --- /dev/null +++ b/internal/alert/alert_test.go @@ -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") + } +} diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go index 295443d..03ec751 100644 --- a/internal/cluster/cluster.go +++ b/internal/cluster/cluster.go @@ -1,6 +1,7 @@ package cluster import ( + "context" "fmt" "go-upkeep/internal/monitor" "net/http" @@ -14,13 +15,13 @@ type Config struct { SharedKey string // Security Key } -func Start(cfg Config) { +func Start(ctx context.Context, cfg Config, eng *monitor.Engine) { if cfg.Mode == "leader" { fmt.Println("Cluster: Running as LEADER (Active)") if cfg.SharedKey != "" { fmt.Println("WARNING: Cluster mode enabled. Ensure the HTTP server is behind a TLS-terminating proxy.") } - monitor.SetEngineActive(true) + eng.SetActive(true) return } @@ -29,20 +30,24 @@ func Start(cfg Config) { if cfg.PeerURL != "" && !strings.HasPrefix(cfg.PeerURL, "https://") { fmt.Println("WARNING: Cluster peer URL is not HTTPS. Cluster secret will be sent in cleartext.") } - monitor.SetEngineActive(false) - go runFollowerLoop(cfg) + eng.SetActive(false) + 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} - - // Failover Configuration failures := 0 threshold := 3 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) if cfg.SharedKey != "" { @@ -59,17 +64,15 @@ func runFollowerLoop(cfg Config) { if isLeaderHealthy { failures = 0 - if monitor.IsEngineActive() { - // Leader is back, yield - monitor.SetEngineActive(false) - monitor.AddLog("Cluster: Leader detected. Switching to PASSIVE.") + if eng.IsActive() { + eng.SetActive(false) + eng.AddLog("Cluster: Leader detected. Switching to PASSIVE.") } } else { failures++ - // If failures exceed threshold, take over - if failures >= threshold && !monitor.IsEngineActive() { - monitor.SetEngineActive(true) - monitor.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.") + if failures >= threshold && !eng.IsActive() { + eng.SetActive(true) + eng.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.") } } } diff --git a/internal/cluster/probe.go b/internal/cluster/probe.go new file mode 100644 index 0000000..6df0a36 --- /dev/null +++ b/internal/cluster/probe.go @@ -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(): + } +} diff --git a/internal/config/apply.go b/internal/config/apply.go new file mode 100644 index 0000000..ca37e36 --- /dev/null +++ b/internal/config/apply.go @@ -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() +} diff --git a/internal/config/apply_test.go b/internal/config/apply_test.go new file mode 100644 index 0000000..824fd61 --- /dev/null +++ b/internal/config/apply_test.go @@ -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") + } +} diff --git a/internal/config/export.go b/internal/config/export.go new file mode 100644 index 0000000..a8cb981 --- /dev/null +++ b/internal/config/export.go @@ -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 +} diff --git a/internal/config/export_test.go b/internal/config/export_test.go new file mode 100644 index 0000000..16b5534 --- /dev/null +++ b/internal/config/export_test.go @@ -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) + } + } +} diff --git a/internal/config/types.go b/internal/config/types.go new file mode 100644 index 0000000..8613d7f --- /dev/null +++ b/internal/config/types.go @@ -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"` +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..e9a2f2d --- /dev/null +++ b/internal/config/validate.go @@ -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 +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..fdcfd42 --- /dev/null +++ b/internal/config/validate_test.go @@ -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) + } +} diff --git a/internal/metrics/prometheus.go b/internal/metrics/prometheus.go new file mode 100644 index 0000000..a85493f --- /dev/null +++ b/internal/metrics/prometheus.go @@ -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) +} diff --git a/internal/metrics/prometheus_test.go b/internal/metrics/prometheus_test.go new file mode 100644 index 0000000..1e2b72b --- /dev/null +++ b/internal/metrics/prometheus_test.go @@ -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) + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go index f79c9c5..14a97c3 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -24,6 +24,8 @@ type Site struct { DNSResolveType string DNSServer string IgnoreTLS bool + Paused bool + Regions string FailureCount int Status string @@ -49,7 +51,22 @@ type User struct { 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 { Sites []Site `json:"sites"` Alerts []AlertConfig `json:"alerts"` diff --git a/internal/monitor/aggregator.go b/internal/monitor/aggregator.go new file mode 100644 index 0000000..88054c8 --- /dev/null +++ b/internal/monitor/aggregator.go @@ -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 +} diff --git a/internal/monitor/checker.go b/internal/monitor/checker.go new file mode 100644 index 0000000..be62155 --- /dev/null +++ b/internal/monitor/checker.go @@ -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 +} diff --git a/internal/monitor/history.go b/internal/monitor/history.go index c6fbcbe..69a7f4d 100644 --- a/internal/monitor/history.go +++ b/internal/monitor/history.go @@ -1,11 +1,8 @@ package monitor -import ( - "sync" - "time" -) +import "time" -const maxHistoryLen = 30 +const maxHistoryLen = 60 type SiteHistory struct { Latencies []time.Duration @@ -14,19 +11,39 @@ type SiteHistory struct { UpChecks int } -var ( - histories = make(map[int]*SiteHistory) - historyMu sync.RWMutex -) +func (e *Engine) InitHistory() { + all, err := e.db.LoadAllHistory(maxHistoryLen) + 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) { - historyMu.Lock() - defer historyMu.Unlock() +func (e *Engine) recordCheck(siteID int, latency time.Duration, isUp bool) { + e.histMu.Lock() + defer e.histMu.Unlock() - h, ok := histories[siteID] + h, ok := e.histories[siteID] if !ok { h = &SiteHistory{} - histories[siteID] = h + e.histories[siteID] = h } h.TotalChecks++ @@ -43,12 +60,14 @@ func RecordCheck(siteID int, latency time.Duration, isUp bool) { if 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) { - historyMu.RLock() - defer historyMu.RUnlock() - h, ok := histories[siteID] +func (e *Engine) GetHistory(siteID int) (SiteHistory, bool) { + e.histMu.RLock() + defer e.histMu.RUnlock() + h, ok := e.histories[siteID] if !ok { return SiteHistory{}, false } @@ -63,8 +82,8 @@ func GetHistory(siteID int) (SiteHistory, bool) { return cp, true } -func RemoveHistory(siteID int) { - historyMu.Lock() - defer historyMu.Unlock() - delete(histories, siteID) +func (e *Engine) removeHistory(siteID int) { + e.histMu.Lock() + defer e.histMu.Unlock() + delete(e.histories, siteID) } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 791aac1..07a0f41 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -1,285 +1,367 @@ package monitor import ( + "context" "crypto/tls" "fmt" "go-upkeep/internal/alert" "go-upkeep/internal/models" "go-upkeep/internal/store" - "net" "net/http" - "strconv" "sync" "time" - - "github.com/miekg/dns" - probing "github.com/prometheus-community/pro-bing" ) -// --- LOGGING --- -var ( - LogStore []string - LogMutex sync.RWMutex -) +type Engine struct { + mu sync.RWMutex + liveState map[int]models.Site -func AddLog(msg string) { - LogMutex.Lock() - defer LogMutex.Unlock() - ts := time.Now().Format("15:04:05") - entry := fmt.Sprintf("[%s] %s", ts, msg) - LogStore = append([]string{entry}, LogStore...) - if len(LogStore) > 100 { - LogStore = LogStore[:100] + logMu sync.RWMutex + logStore []string + + activeMu sync.RWMutex + isActive bool + + histMu sync.RWMutex + 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 { - LogMutex.RLock() - defer LogMutex.RUnlock() - logs := make([]string, len(LogStore)) - copy(logs, LogStore) +func (e *Engine) SetInsecureSkipVerify(skip bool) { + e.insecureSkipVerify = skip +} + +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 } -// --- ENGINE --- - -var ( - LiveState = make(map[int]models.Site) - Mutex sync.RWMutex - - // 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 +func (e *Engine) SetActive(active bool) { + e.activeMu.Lock() + defer e.activeMu.Unlock() + if e.isActive != active { + e.isActive = active status := "RESUMED (Active)" if !active { status = "PAUSED (Passive)" } - AddLog(fmt.Sprintf("Engine %s", status)) + e.AddLog(fmt.Sprintf("Engine %s", status)) } } -func IsEngineActive() bool { - activeMutex.RLock() - defer activeMutex.RUnlock() - return isActive +func (e *Engine) IsActive() bool { + e.activeMu.RLock() + defer e.activeMu.RUnlock() + return e.isActive } -func RecordHeartbeat(token string) bool { - if !IsEngineActive() { - return false - } // Only Leader accepts Push - - Mutex.Lock() - defer Mutex.Unlock() - var targetID int = -1 - for id, s := range LiveState { - if s.Type == "push" && s.Token == token { - targetID = id - break - } +func (e *Engine) GetAllSites() []models.Site { + e.mu.RLock() + defer e.mu.RUnlock() + sites := make([]models.Site, 0, len(e.liveState)) + for _, s := range e.liveState { + sites = append(sites, s) } - if targetID == -1 { + return sites +} + +func (e *Engine) GetLiveState() map[int]models.Site { + e.mu.RLock() + defer e.mu.RUnlock() + cp := make(map[int]models.Site, len(e.liveState)) + for k, v := range e.liveState { + cp[k] = v + } + return cp +} + +func (e *Engine) RecordHeartbeat(token string) bool { + if !e.IsActive() { + return false + } + + e.mu.Lock() + defer e.mu.Unlock() + + targetID, ok := e.tokenIndex[token] + if !ok { + return false + } + + site, exists := e.liveState[targetID] + if !exists { return false } - site := LiveState[targetID] site.LastCheck = time.Now() wasDown := site.Status == "DOWN" site.Status = "UP" site.FailureCount = 0 site.Latency = 0 - LiveState[targetID] = site + e.liveState[targetID] = site if wasDown { - AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) - triggerAlert(site.AlertID, "โœ… RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) + e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) + e.triggerAlert(site.AlertID, "โœ… RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) } return true } -func 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() { for { - s_instance := store.Get() - if s_instance == nil { - time.Sleep(1 * time.Second) - continue + select { + case <-ctx.Done(): + return + 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 { - Mutex.RLock() - _, exists := LiveState[s.ID] - Mutex.RUnlock() + e.mu.RLock() + _, exists := e.liveState[s.ID] + e.mu.RUnlock() if !exists { - Mutex.Lock() + e.mu.Lock() s.Status = "PENDING" if s.Type == "push" { s.LastCheck = time.Now() } - LiveState[s.ID] = s - Mutex.Unlock() - go monitorRoutine(s.ID) + if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 { + if h.Statuses[len(h.Statuses)-1] { + s.Status = "UP" + } else { + s.Status = "DOWN" + } + if len(h.Latencies) > 0 { + s.Latency = h.Latencies[len(h.Latencies)-1] + } + } + e.liveState[s.ID] = s + e.addToTokenIndex(s) + e.mu.Unlock() + go e.monitorRoutine(ctx, s.ID) } } - time.Sleep(5 * time.Second) + + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } } }() } -func UpdateSiteConfig(site models.Site) { - Mutex.Lock() - defer Mutex.Unlock() - if s, ok := LiveState[site.ID]; ok { - s.Name = site.Name - s.URL = site.URL - s.Type = site.Type - s.Interval = site.Interval - s.AlertID = site.AlertID - s.CheckSSL = site.CheckSSL - s.ExpiryThreshold = site.ExpiryThreshold - s.MaxRetries = site.MaxRetries - s.Hostname = site.Hostname - s.Port = site.Port - s.Timeout = site.Timeout - 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 (e *Engine) UpdateSiteConfig(site models.Site) { + e.mu.Lock() + defer e.mu.Unlock() + if existing, ok := e.liveState[site.ID]; ok { + e.removeFromTokenIndex(site.ID) + site.Status = existing.Status + site.StatusCode = existing.StatusCode + site.Latency = existing.Latency + site.CertExpiry = existing.CertExpiry + site.HasSSL = existing.HasSSL + site.LastCheck = existing.LastCheck + site.SentSSLWarning = existing.SentSSLWarning + site.FailureCount = existing.FailureCount + e.liveState[site.ID] = site + e.addToTokenIndex(site) } } -func RemoveSite(id int) { - Mutex.Lock() - delete(LiveState, id) - Mutex.Unlock() - RemoveHistory(id) +func (e *Engine) RemoveSite(id int) { + e.mu.Lock() + e.removeFromTokenIndex(id) + delete(e.liveState, id) + e.mu.Unlock() + e.removeHistory(id) } -func monitorRoutine(id int) { - checkByID(id) +func (e *Engine) ToggleSitePause(id int) bool { + e.mu.Lock() + defer e.mu.Unlock() + site, ok := e.liveState[id] + if !ok { + return false + } + site.Paused = !site.Paused + e.liveState[id] = site + if site.Paused { + e.AddLog(fmt.Sprintf("Monitor '%s' paused", site.Name)) + } else { + e.AddLog(fmt.Sprintf("Monitor '%s' resumed", site.Name)) + } + return site.Paused +} + +func (e *Engine) monitorRoutine(ctx context.Context, id int) { + e.checkByID(id) for { - // If paused, just sleep loop to keep goroutine alive but idle - if !IsEngineActive() { - time.Sleep(5 * time.Second) + select { + case <-ctx.Done(): + return + default: + } + + if !e.IsActive() { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } continue } - Mutex.RLock() - site, exists := LiveState[id] - Mutex.RUnlock() + e.mu.RLock() + site, exists := e.liveState[id] + e.mu.RUnlock() if !exists { return } + if site.Paused { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return + } + continue + } + interval := site.Interval if interval < 5 { interval = 5 } - time.Sleep(time.Duration(interval) * time.Second) - checkByID(id) + select { + case <-time.After(time.Duration(interval) * time.Second): + case <-ctx.Done(): + return + } + e.checkByID(id) } } -func checkByID(id int) { - if !IsEngineActive() { +func (e *Engine) checkByID(id int) { + if !e.IsActive() { return } - Mutex.RLock() - site, exists := LiveState[id] - Mutex.RUnlock() - if !exists { + e.mu.RLock() + site, exists := e.liveState[id] + e.mu.RUnlock() + if !exists || site.Paused { return } + switch site.Type { - case "http": - checkHTTP(site) case "push": - checkPush(site) - case "ping": - checkPing(site) - case "port": - checkPort(site) - case "dns": - checkDNS(site) + e.checkPush(site) 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) if time.Now().After(deadline) { - handleStatusChange(site, "DOWN", 0, 0) - } else { - if site.Status != "UP" { - handleStatusChange(site, "UP", 200, 0) - } + e.handleStatusChange(site, "DOWN", 0, 0) + } else if site.Status != "UP" { + e.handleStatusChange(site, "UP", 200, 0) } } -func checkHTTP(site models.Site) { - start := time.Now() - 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() { +func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) { + if !e.IsActive() { return } @@ -291,9 +373,9 @@ func handleStatusChange(site models.Site, rawStatus string, code int, latency ti if newState.FailureCount > site.MaxRetries { newState.Status = rawStatus 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 { - 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" { 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 { daysLeft := int(time.Until(site.CertExpiry).Hours() / 24) 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 } else if daysLeft > site.ExpiryThreshold { newState.SentSSLWarning = false } } - Mutex.Lock() - if _, ok := LiveState[site.ID]; ok { - LiveState[site.ID] = newState + e.mu.Lock() + if _, ok := e.liveState[site.ID]; ok { + 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" } 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" { 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" { - 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) { - s_instance := store.Get() - if s_instance == nil { - return - } - cfg, ok := s_instance.GetAlert(alertID) - if !ok { +func (e *Engine) triggerAlert(alertID int, title, message string) { + cfg, err := e.db.GetAlert(alertID) + if err != nil { return } provider := alert.GetProvider(cfg) 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 { - if site.Timeout > 0 { - return time.Duration(site.Timeout) * time.Second +func (e *Engine) checkGroup(site models.Site) { + e.mu.RLock() + status := "UP" + hasChildren := false + allPaused := true + for _, child := range e.liveState { + if child.ParentID != site.ID || child.Type == "group" { + continue + } + hasChildren = true + if !child.Paused { + allPaused = false + } + if child.Paused { + continue + } + if child.Status == "DOWN" || child.Status == "SSL EXP" { + status = "DOWN" + } else if child.Status == "PENDING" && status != "DOWN" { + status = "PENDING" + } } - return 5 * time.Second + 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) { - host := site.Hostname - if host == "" { - host = site.URL +func (e *Engine) SetAggStrategy(strategy AggregationStrategy) { + e.aggStrategy = strategy +} + +func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool) { + e.probeResultsMu.Lock() + if e.probeResults[siteID] == nil { + e.probeResults[siteID] = make(map[string]NodeResult) } - - pinger, err := probing.NewPinger(host) - if err != nil { - handleStatusChange(site, "DOWN", 0, 0) - AddLog(fmt.Sprintf("Ping '%s' resolve failed: %v", site.Name, err)) - return + e.probeResults[siteID][nodeID] = NodeResult{ + NodeID: nodeID, + IsUp: isUp, + LatencyNs: latencyNs, + CheckedAt: time.Now(), } - pinger.Count = 1 - pinger.Timeout = siteTimeout(site) - pinger.SetPrivileged(false) + results := make([]NodeResult, 0, len(e.probeResults[siteID])) + for _, r := range e.probeResults[siteID] { + results = append(results, r) + } + e.probeResultsMu.Unlock() - start := time.Now() - err = pinger.Run() - latency := time.Since(start) + aggUp, avgLatency := AggregateStatus(results, e.aggStrategy) - if err != nil || pinger.Statistics().PacketsRecv == 0 { - updatedSite := site - updatedSite.Latency = latency - updatedSite.LastCheck = time.Now() - handleStatusChange(updatedSite, "DOWN", 0, latency) + e.mu.RLock() + site, exists := e.liveState[siteID] + e.mu.RUnlock() + if !exists { return } - stats := pinger.Statistics() - updatedSite := site - updatedSite.Latency = stats.AvgRtt - updatedSite.LastCheck = time.Now() - handleStatusChange(updatedSite, "UP", 0, stats.AvgRtt) -} - -func checkPort(site models.Site) { - host := site.Hostname - if host == "" { - host = site.URL + rawStatus := "UP" + if !aggUp { + rawStatus = "DOWN" } - 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.Latency = latency + updatedSite.Latency = time.Duration(avgLatency) updatedSite.LastCheck = time.Now() - - if err != nil { - handleStatusChange(updatedSite, "DOWN", 0, latency) - return - } - conn.Close() - handleStatusChange(updatedSite, "UP", 0, latency) + e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency)) } -func checkDNS(site models.Site) { - host := site.Hostname - if host == "" { - host = site.URL +func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult { + e.probeResultsMu.RLock() + defer e.probeResultsMu.RUnlock() + src := e.probeResults[siteID] + cp := make(map[string]NodeResult, len(src)) + for k, v := range src { + cp[k] = v } - - 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) + return cp } diff --git a/internal/server/server.go b/internal/server/server.go index 6e857b4..49f21a2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,14 +4,145 @@ import ( "encoding/json" "fmt" "go-upkeep/internal/importer" + "go-upkeep/internal/metrics" "go-upkeep/internal/models" "go-upkeep/internal/monitor" "go-upkeep/internal/store" "html/template" + "log" "net/http" "sort" + "strings" ) +var statusTpl = template.Must(template.New("status").Parse(` + + + + {{.Title}} + + + + +
+

{{.Title}}

+
+
+
+
Powered by Go-Upkeep
+
+ + +`)) + type ServerConfig struct { Port int EnableStatus bool @@ -19,7 +150,7 @@ type ServerConfig struct { ClusterKey string // Shared Secret for Security } -func Start(cfg ServerConfig) { +func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) { if cfg.ClusterKey == "" { 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) return } - if monitor.RecordHeartbeat(token) { + if eng.RecordHeartbeat(token) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) } else { @@ -56,7 +187,12 @@ func Start(cfg ServerConfig) { http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) 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) }) @@ -75,8 +211,9 @@ func Start(cfg ServerConfig) { http.Error(w, "Invalid JSON", 400) return } - if err := store.Get().ImportData(data); err != nil { - http.Error(w, "Import Failed: "+err.Error(), 500) + if err := s.ImportData(data); err != nil { + log.Printf("Import failed: %v", err) + http.Error(w, "Import failed", 500) return } w.Write([]byte("Import Successful")) @@ -94,42 +231,154 @@ func Start(cfg ServerConfig) { } var kb importer.KumaBackup 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 } backup := importer.ConvertKuma(&kb) - if err := store.Get().ImportData(backup); err != nil { - http.Error(w, "Import Failed: "+err.Error(), 500) + if err := s.ImportData(backup); err != nil { + log.Printf("Kuma import failed: %v", err) + http.Error(w, "Import failed", 500) return } 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 + mux.HandleFunc("/api/probe/register", 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 { + 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") + 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) }) + 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) { - monitor.Mutex.RLock() - defer monitor.Mutex.RUnlock() + state := eng.GetLiveState() + for id, site := range state { + site.Token = "" + state[id] = site + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(monitor.LiveState) + json.NewEncoder(w).Encode(state) }) } go func() { addr := fmt.Sprintf(":%d", cfg.Port) 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) { - monitor.Mutex.RLock() - var sites []models.Site - for _, s := range monitor.LiveState { - sites = append(sites, s) - } - monitor.Mutex.RUnlock() +func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { + sites := eng.GetAllSites() sort.Slice(sites, func(i, j int) bool { if sites[i].Status != sites[j].Status { @@ -143,53 +392,9 @@ func renderStatusPage(w http.ResponseWriter, title string) { return sites[i].Name < sites[j].Name }) - const tpl = ` - - - - {{.Title}} - - - - - -
-

{{.Title}}

- {{range .Sites}} -
-
-
{{.Name}}
-
{{.Type}} | {{if eq .Type "http"}}{{.URL}}{{else}}Heartbeat Monitor{{end}}
-
Last Check: {{.LastCheck.Format "15:04:05"}}
-
-
{{.Status}}
-
- {{end}} -
Powered by Go-Upkeep
-
- - - ` - - t, _ := template.New("status").Parse(tpl) data := struct { Title string Sites []models.Site }{Title: title, Sites: sites} - t.Execute(w, data) + statusTpl.Execute(w, data) } diff --git a/internal/store/dialect.go b/internal/store/dialect.go new file mode 100644 index 0000000..f6e35b2 --- /dev/null +++ b/internal/store/dialect.go @@ -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) +} diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 34ce96b..f8b5abc 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -2,67 +2,65 @@ package store import ( "database/sql" - "encoding/json" - "go-upkeep/internal/models" _ "github.com/lib/pq" ) -type PostgresStore struct { - ConnStr string - db *sql.DB +type PostgresDialect struct{} + +func NewPostgresStore(connStr string) (*SQLStore, error) { + return NewSQLStore("postgres", connStr, &PostgresDialect{}) } -func (p *PostgresStore) Init() error { - var err error - p.db, err = sql.Open("postgres", p.ConnStr) - if err != nil { - return err - } +func (d *PostgresDialect) DriverName() string { return "postgres" } +func (d *PostgresDialect) BoolFalse() string { return "FALSE" } - queries := []string{ +func (d *PostgresDialect) CreateTablesSQL() []string { + return []string{ `CREATE TABLE IF NOT EXISTS alerts ( id SERIAL PRIMARY KEY, - name TEXT, - type TEXT, - settings TEXT - );`, + name TEXT, type TEXT, settings TEXT + )`, `CREATE TABLE IF NOT EXISTS sites ( id SERIAL PRIMARY KEY, - name TEXT DEFAULT 'New Monitor', - url TEXT, - type TEXT DEFAULT 'http', - token TEXT, - interval INTEGER, - alert_id INTEGER, - check_ssl BOOLEAN DEFAULT FALSE, - threshold INTEGER DEFAULT 7, - max_retries INTEGER DEFAULT 0, - 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 - );`, + name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http', + token TEXT, interval INTEGER, alert_id INTEGER, + check_ssl BOOLEAN DEFAULT FALSE, threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0, 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, paused BOOLEAN DEFAULT FALSE + )`, `CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, - username TEXT NOT NULL, - public_key TEXT NOT NULL, + username TEXT NOT NULL, public_key TEXT NOT NULL, role TEXT DEFAULT 'user' - );`, - } - for _, q := range queries { - if _, err := p.db.Exec(q); err != nil { - return err - } + )`, + `CREATE TABLE IF NOT EXISTS check_history ( + id SERIAL PRIMARY KEY, + site_id INTEGER NOT NULL, latency_ns BIGINT, + is_up BOOLEAN, checked_at TIMESTAMP DEFAULT NOW() + )`, + `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 port 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_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN IF NOT EXISTS ignore_tls BOOLEAN DEFAULT FALSE", - } - for _, m := range migrations { - p.db.Exec(m) - } - - 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(), + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS paused BOOLEAN DEFAULT FALSE", + "ALTER TABLE check_history ADD COLUMN IF NOT EXISTS node_id TEXT DEFAULT ''", + "ALTER TABLE sites ADD COLUMN IF NOT EXISTS regions TEXT DEFAULT ''", } } -func (p *PostgresStore) ImportData(data models.Backup) error { - tx, err := p.db.Begin() - if err != nil { - return err - } +func (d *PostgresDialect) UpsertNodeSQL() string { + return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version" +} +func (d *PostgresDialect) 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 alerts 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) - } - for _, a := range data.Alerts { - jsonBytes, _ := json.Marshal(a.Settings) - 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() +} + +func (d *PostgresDialect) ImportResetSequences(tx *sql.Tx) { + tx.Exec("SELECT setval('sites_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sites))") + tx.Exec("SELECT setval('alerts_id_seq', (SELECT COALESCE(MAX(id), 1) FROM alerts))") + tx.Exec("SELECT setval('users_id_seq', (SELECT COALESCE(MAX(id), 1) FROM users))") } diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index d55a35e..ea2cacc 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -1,68 +1,66 @@ package store import ( - "crypto/rand" "database/sql" - "encoding/hex" - "encoding/json" - "go-upkeep/internal/models" _ "github.com/mattn/go-sqlite3" ) -type SQLiteStore struct { - DBPath string - db *sql.DB +type SQLiteDialect struct{} + +func NewSQLiteStore(path string) (*SQLStore, error) { + return NewSQLStore("sqlite3", path, &SQLiteDialect{}) } -func (s *SQLiteStore) Init() error { - var err error - s.db, err = sql.Open("sqlite3", s.DBPath) - if err != nil { - return err - } +func (d *SQLiteDialect) DriverName() string { return "sqlite3" } +func (d *SQLiteDialect) BoolFalse() string { return "0" } - createTables := ` - CREATE TABLE IF NOT EXISTS alerts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - type TEXT, - settings TEXT - ); - CREATE TABLE IF NOT EXISTS sites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT DEFAULT 'New Monitor', - url TEXT, - type TEXT DEFAULT 'http', - token TEXT, - interval INTEGER, - alert_id INTEGER, - check_ssl BOOLEAN DEFAULT 0, - threshold INTEGER DEFAULT 7, - max_retries INTEGER DEFAULT 0, - 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 0 - ); - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - public_key TEXT NOT NULL, - role TEXT DEFAULT 'user' - );` - _, err = s.db.Exec(createTables) - if err != nil { - return err +func (d *SQLiteDialect) CreateTablesSQL() []string { + return []string{ + `CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, type TEXT, settings TEXT + )`, + `CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT DEFAULT 'New Monitor', url TEXT, type TEXT DEFAULT 'http', + token TEXT, interval INTEGER, alert_id INTEGER, + check_ssl BOOLEAN DEFAULT 0, threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0, 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 0, paused BOOLEAN DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, public_key TEXT NOT NULL, + role TEXT DEFAULT 'user' + )`, + `CREATE TABLE IF NOT EXISTS check_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, latency_ns INTEGER, + is_up BOOLEAN, checked_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `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 port 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_server TEXT DEFAULT ''", "ALTER TABLE sites ADD COLUMN ignore_tls BOOLEAN DEFAULT 0", + "ALTER TABLE sites ADD COLUMN paused BOOLEAN DEFAULT 0", + "ALTER TABLE check_history ADD COLUMN node_id TEXT DEFAULT ''", + "ALTER TABLE sites ADD COLUMN regions TEXT DEFAULT ''", } - for _, m := range migrations { - s.db.Exec(m) - } - - return nil } -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 (d *SQLiteDialect) UpsertNodeSQL() string { + return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)" } -func (s *SQLiteStore) GetSites() []models.Site { - 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) +func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) { var count int - s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count) + db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) if count == 0 { - s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") - } -} -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(), + db.Exec("DELETE FROM sqlite_sequence WHERE name=?", table) } } -func (s *SQLiteStore) ImportData(data models.Backup) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - - // Wipe Existing +func (d *SQLiteDialect) ImportWipe(tx *sql.Tx) { tx.Exec("DELETE FROM sites") tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") tx.Exec("DELETE FROM alerts") tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") tx.Exec("DELETE FROM 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) {} diff --git a/internal/store/sqlstore.go b/internal/store/sqlstore.go new file mode 100644 index 0000000..294fd19 --- /dev/null +++ b/internal/store/sqlstore.go @@ -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() +} diff --git a/internal/store/sqlstore_test.go b/internal/store/sqlstore_test.go new file mode 100644 index 0000000..7ae2bd9 --- /dev/null +++ b/internal/store/sqlstore_test.go @@ -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) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index e9c05ac..fe96e37 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,35 +8,48 @@ type Store interface { Init() error // Sites - GetSites() []models.Site - AddSite(site models.Site) - UpdateSite(site models.Site) - DeleteSite(id int) + GetSites() ([]models.Site, error) + AddSite(site models.Site) error + UpdateSite(site models.Site) error + UpdateSitePaused(id int, paused bool) error + DeleteSite(id int) error // Alerts - GetAllAlerts() []models.AlertConfig - GetAlert(id int) (models.AlertConfig, bool) - AddAlert(name, aType string, settings map[string]string) - UpdateAlert(id int, name, aType string, settings map[string]string) - DeleteAlert(id int) + GetAllAlerts() ([]models.AlertConfig, error) + GetAlert(id int) (models.AlertConfig, error) + AddAlert(name, aType string, settings map[string]string) error + UpdateAlert(id int, name, aType string, settings map[string]string) error + DeleteAlert(id int) error + + // 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 - GetAllUsers() []models.User + GetAllUsers() ([]models.User, error) AddUser(username, publicKey, role string) error UpdateUser(id int, username, publicKey, role string) error DeleteUser(id int) error - // Phase 5: Backup & Restore - ExportData() models.Backup + // History + SaveCheck(siteID int, latencyNs int64, isUp bool) error + SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error + LoadAllHistory(limit int) (map[int][]models.CheckRecord, error) + + // 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 } - -var Current Store - -func SetGlobal(s Store) { - Current = s -} - -func Get() Store { - return Current -} diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index c0ee2ec..342e1bd 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -2,32 +2,10 @@ package tui import ( "fmt" - "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "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 { @@ -45,6 +23,19 @@ type alertFormData struct { NtfyUser string NtfyPass 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 { @@ -59,6 +50,14 @@ func fmtAlertType(t string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t) case "ntfy": 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: return t } @@ -86,6 +85,26 @@ func fmtAlertConfig(alert struct { return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) } 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: if val, ok := alert.Settings["url"]; ok { return limitStr(val, 34) @@ -99,57 +118,35 @@ func (m Model) viewAlertsTab() string { return "\n No alert channels configured. Press [n] to add one." } - end := m.tableOffset + m.maxTableRows - if end > len(m.alerts) { - end = len(m.alerts) - } - - selectedVisual := m.cursor - m.tableOffset - - var rows [][]string - for i := m.tableOffset; i < end; i++ { - alert := m.alerts[i] - rows = append(rows, []string{ - fmt.Sprintf("%d", alert.ID), - m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(alert.Name, 15)), - fmtAlertType(alert.Type), - fmtAlertConfig(struct { - Type string - Settings map[string]string - }{alert.Type, alert.Settings}), - }) - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - 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 + return m.renderTable( + []string{"#", "NAME", "TYPE", "CONFIG"}, + len(m.alerts), + func(start, end int) [][]string { + var rows [][]string + for i := start; i < end; i++ { + a := m.alerts[i] + rows = append(rows, []string{ + fmt.Sprintf("%d", i+1), + m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)), + fmtAlertType(a.Type), + fmtAlertConfig(struct { + Type string + Settings map[string]string + }{a.Type, a.Settings}), + }) } - s := alertCellStyle - if row == selectedVisual { - s = alertSelectedStyle - } - if col < len(alertColWidths) { - s = s.Width(alertColWidths[col]) - } - return s - }) - - return "\n" + t.Render() + return rows + }, + nil, nil, + ) } func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ - AlertType: "discord", - NtfyPri: "3", + AlertType: "discord", + NtfyPri: "3", + PagerDutySeverity: "critical", + GotifyPriority: "5", } if m.editID > 0 { @@ -174,6 +171,19 @@ func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData.NtfyUser = alert.Settings["username"] m.alertFormData.NtfyPass = alert.Settings["password"] 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 } @@ -198,6 +208,10 @@ func (m *Model) initAlertHuhForm() tea.Cmd { huh.NewOption("Webhook", "webhook"), huh.NewOption("Email (SMTP)", "email"), huh.NewOption("Ntfy", "ntfy"), + huh.NewOption("Telegram", "telegram"), + huh.NewOption("PagerDuty", "pagerduty"), + huh.NewOption("Pushover", "pushover"), + huh.NewOption("Gotify", "gotify"), ).Value(&m.alertFormData.AlertType), ).Title("Alert Config"), huh.NewGroup( @@ -205,7 +219,8 @@ func (m *Model) initAlertHuhForm() tea.Cmd { Placeholder("https://discord.com/api/webhooks/..."). Value(&m.alertFormData.WebhookURL), ).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.NewInput().Title("Ntfy Server URL"). @@ -253,6 +268,57 @@ func (m *Model) initAlertHuhForm() tea.Cmd { ).Title("Email Settings").WithHideFunc(func() bool { 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()) return m.huhForm.Init() @@ -276,14 +342,31 @@ func (m *Model) submitAlertForm() { settings["priority"] = d.NtfyPri settings["username"] = d.NtfyUser 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: settings["url"] = d.WebhookURL } 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 { - 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 } diff --git a/internal/tui/tab_nodes.go b/internal/tui/tab_nodes.go new file mode 100644 index 0000000..60f9522 --- /dev/null +++ b/internal/tui/tab_nodes.go @@ -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 +} diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index 2ac265b..6f7df4c 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -3,8 +3,6 @@ package tui import ( "fmt" "go-upkeep/internal/models" - "go-upkeep/internal/monitor" - "go-upkeep/internal/store" "net/url" "strconv" "strings" @@ -13,45 +11,55 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" ) var sparkChars = []rune{'โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ'} -var ( - siteHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7D56F4")). - Bold(true). - Padding(0, 1) +func typeIcon(siteType string, collapsed bool) string { + switch siteType { + case "http": + return "โ†’" + case "push": + return "โ†“" + case "ping": + return "โ†”" + case "port": + return "โŠก" + case "dns": + return "โ—†" + case "group": + if collapsed { + return "๏ป" + } + return "๏ผ" + default: + return "ยท" + } +} - siteCellStyle = lipgloss.NewStyle().Padding(0, 1) - - siteSelectedStyle = lipgloss.NewStyle(). - Padding(0, 1). - Bold(true). - Foreground(lipgloss.Color("#ffffff")). - Background(lipgloss.Color("#3b3b5c")) - - siteBorderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) - - siteColWidths = []int{4, 14, 6, 8, 9, 8, 20, 10, 6} -) +var siteGroupStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#7D56F4")) type siteFormData struct { - Name string - SiteType string - URL string - Interval string - AlertID string - CheckSSL bool - Threshold string - Retries string - Hostname string - Port string - Timeout string - Description string - IgnoreTLS bool + Name string + SiteType string + URL string + Method string + AcceptedCodes string + Interval string + AlertID string + CheckSSL bool + Threshold string + Retries string + Hostname string + Port string + Timeout string + Description string + IgnoreTLS bool + GroupID string + Regions 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 + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) + } spread := maxL - minL for _, l := range samples { idx := 0 @@ -94,10 +105,6 @@ func latencySparkline(latencies []time.Duration, width int) string { sb.WriteString(dangerStyle.Render(ch)) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) - } return sb.String() } @@ -112,6 +119,9 @@ func heartbeatSparkline(statuses []bool, width int) string { } var sb strings.Builder + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) + } for _, up := range samples { if up { sb.WriteString(specialStyle.Render("โ–")) @@ -119,10 +129,6 @@ func heartbeatSparkline(statuses []bool, width int) string { sb.WriteString(dangerStyle.Render("โ–ˆ")) } } - - if remaining := width - len(samples); remaining > 0 { - sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) - } return sb.String() } @@ -146,11 +152,17 @@ func fmtLatency(d time.Duration) string { return dangerStyle.Render(s) } -func fmtUptime(total, up int) string { - if total == 0 { +func fmtUptime(statuses []bool) string { + if len(statuses) == 0 { 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) if pct >= 99 { return specialStyle.Render(s) @@ -195,7 +207,10 @@ func fmtRetries(site models.Site) string { return s } -func fmtStatus(status string) string { +func fmtStatus(status string, paused bool) string { + if paused { + return warnStyle.Render("PAUSED") + } switch { case status == "DOWN" || status == "SSL EXP": return dangerStyle.Render(status) @@ -206,79 +221,130 @@ 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 { - const sparkWidth = 20 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 - if end > len(m.sites) { - end = len(m.sites) - } + nameW, sparkWidth := m.dynamicWidths() + colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9} - 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 + for i := start; i < end; i++ { + site := m.sites[i] - var rows [][]string - for i := m.tableOffset; i < end; i++ { - site := m.sites[i] - hist, _ := monitor.GetHistory(site.ID) - - var spark string - if site.Type == "push" { - spark = heartbeatSparkline(hist.Statuses, sparkWidth) - } else { - spark = latencySparkline(hist.Latencies, sparkWidth) - } - - rows = append(rows, []string{ - strconv.Itoa(site.ID), - m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 13)), - site.Type, - fmtStatus(site.Status), - fmtLatency(site.Latency), - fmtUptime(hist.TotalChecks, hist.UpChecks), - spark, - fmtSSL(site), - fmtRetries(site), - }) - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - BorderStyle(siteBorderStyle). - Headers("ID", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). - Rows(rows...). - StyleFunc(func(row, col int) lipgloss.Style { - if row == table.HeaderRow { - s := siteHeaderStyle - if col < len(siteColWidths) { - s = s.Width(siteColWidths[col]) + 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 } - return s - } - s := siteCellStyle - if row == selectedVisual { - s = siteSelectedStyle - } - if col < len(siteColWidths) { - s = s.Width(siteColWidths[col]) - } - return s - }) - return "\n" + t.Render() + 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 + if site.Type == "push" { + spark = heartbeatSparkline(hist.Statuses, sparkWidth) + } else { + spark = latencySparkline(hist.Latencies, sparkWidth) + } + + rows = append(rows, []string{ + strconv.Itoa(i + 1), + m.zones.Mark(fmt.Sprintf("site-%d", i), name), + typeIcon(site.Type, false) + " " + site.Type, + fmtStatus(site.Status, site.Paused), + fmtLatency(site.Latency), + fmtUptime(hist.Statuses), + spark, + fmtSSL(site), + fmtRetries(site), + }) + } + return rows + }, + colWidths, + func(row, col int) *lipgloss.Style { + if groupRows[row] { + s := siteGroupStyle + return &s + } + return nil + }, + ) } func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData = &siteFormData{ - SiteType: "http", - Interval: "60", - Threshold: "7", - Retries: "0", - Timeout: "5", - Port: "0", + SiteType: "http", + Method: "GET", + AcceptedCodes: "200-299", + Interval: "60", + Threshold: "7", + Retries: "0", + Timeout: "5", + Port: "0", + GroupID: "0", } if m.editID > 0 { @@ -297,14 +363,18 @@ func (m *Model) initSiteHuhForm() tea.Cmd { m.siteFormData.Timeout = strconv.Itoa(site.Timeout) m.siteFormData.Description = site.Description 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 } } } alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} - if store.Get() != nil { - for _, a := range store.Get().GetAllAlerts() { + if alerts, err := m.store.GetAllAlerts(); err == nil { + for _, a := range alerts { alertOpts = append(alertOpts, huh.NewOption( fmt.Sprintf("%s (%s)", a.Name, a.Type), 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( huh.NewGroup( huh.NewInput().Title("Monitor Name"). @@ -332,12 +409,17 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewOption("DNS", "dns"), huh.NewOption("Group", "group"), ).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"). Placeholder("https://example.com"). Description("Required for HTTP monitors"). Value(&m.siteFormData.URL). Validate(func(s string) error { - if m.siteFormData.SiteType == "push" { + if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" { return nil } if s == "" { @@ -357,12 +439,23 @@ func (m *Model) initSiteHuhForm() tea.Cmd { }), huh.NewInput().Title("Check Interval (seconds)"). Placeholder("60"). - Value(&m.siteFormData.Interval), - huh.NewSelect[string]().Title("Alert Channel"). - Options(alertOpts...). - Value(&m.siteFormData.AlertID), - ).Title("Monitor Settings"), - huh.NewGroup( + Value(&m.siteFormData.Interval). + 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 < 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"). Placeholder("10.0.0.1"). Description("Target for ping/port/DNS monitors"). @@ -370,26 +463,95 @@ func (m *Model) initSiteHuhForm() tea.Cmd { huh.NewInput().Title("Port"). Placeholder("0"). 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)"). 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"). Placeholder("Optional 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.NewConfirm().Title("Monitor SSL Certificate?"). Value(&m.siteFormData.CheckSSL), huh.NewInput().Title("SSL Warning Threshold (days)"). 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"). 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?"). Value(&m.siteFormData.IgnoreTLS), - ).Title("Advanced"), + ).Title("Advanced").WithHideFunc(func() bool { + return m.siteFormData.SiteType == "group" + }), ).WithTheme(huh.ThemeDracula()) return m.huhForm.Init() @@ -403,6 +565,7 @@ func (m *Model) submitSiteForm() { retries, _ := strconv.Atoi(d.Retries) port, _ := strconv.Atoi(d.Port) timeout, _ := strconv.Atoi(d.Timeout) + groupID, _ := strconv.Atoi(d.GroupID) if interval < 1 { interval = 60 } @@ -425,13 +588,103 @@ func (m *Model) submitSiteForm() { Timeout: timeout, Description: d.Description, IgnoreTLS: d.IgnoreTLS, + ParentID: groupID, + Method: d.Method, + AcceptedCodes: d.AcceptedCodes, + Regions: d.Regions, } if m.editID > 0 { - store.Get().UpdateSite(site) - monitor.UpdateSiteConfig(site) + if err := m.store.UpdateSite(site); err != nil { + m.engine.AddLog("Update site failed: " + err.Error()) + } + m.engine.UpdateSiteConfig(site) } else { - store.Get().AddSite(site) + if err := m.store.AddSite(site); err != nil { + m.engine.AddLog("Add site failed: " + err.Error()) + } } 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()) +} diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go index 48fd9a3..019bb03 100644 --- a/internal/tui/tab_users.go +++ b/internal/tui/tab_users.go @@ -2,32 +2,9 @@ package tui import ( "fmt" - "go-upkeep/internal/store" tea "github.com/charmbracelet/bubbletea" "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 { @@ -55,48 +32,24 @@ func (m Model) viewUsersTab() string { return "\n No users configured. Press [n] to add one." } - end := m.tableOffset + m.maxTableRows - if end > len(m.users) { - end = len(m.users) - } - - selectedVisual := m.cursor - m.tableOffset - - var rows [][]string - for i := m.tableOffset; i < end; i++ { - u := m.users[i] - rows = append(rows, []string{ - fmt.Sprintf("%d", u.ID), - m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), - fmtRole(u.Role), - fmtKey(u.PublicKey), - }) - } - - t := table.New(). - Border(lipgloss.RoundedBorder()). - 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 + return m.renderTable( + []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}, + len(m.users), + func(start, end int) [][]string { + var rows [][]string + for i := start; i < end; i++ { + u := m.users[i] + rows = append(rows, []string{ + fmt.Sprintf("%d", i+1), + m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)), + fmtRole(u.Role), + fmtKey(u.PublicKey), + }) } - s := userCellStyle - if row == selectedVisual { - s = userSelectedStyle - } - if col < len(userColWidths) { - s = s.Width(userColWidths[col]) - } - return s - }) - - return "\n" + t.Render() + return rows + }, + nil, nil, + ) } func (m *Model) initUserHuhForm() tea.Cmd { @@ -149,9 +102,13 @@ func (m *Model) initUserHuhForm() tea.Cmd { func (m *Model) submitUserForm() { d := m.userFormData 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 { - 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 } diff --git a/internal/tui/table_helpers.go b/internal/tui/table_helpers.go new file mode 100644 index 0000000..be8719e --- /dev/null +++ b/internal/tui/table_helpers.go @@ -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() +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7af994b..6f32161 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,7 +19,7 @@ import ( ) 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"}) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) @@ -37,9 +37,11 @@ const ( stateDashboard sessionState = iota stateLogs stateUsers + stateDetail stateFormSite stateFormAlert stateFormUser + stateConfirmDelete ) type Model struct { @@ -48,6 +50,8 @@ type Model struct { cursor int tableOffset int maxTableRows int + termWidth int + termHeight int editID int editToken string @@ -60,6 +64,14 @@ type Model struct { isAdmin bool zones *zone.Manager + deleteID int + deleteName string + deleteTab int + + collapsed map[int]bool + store store.Store + engine *monitor.Engine + // harmonica animation state pulseSpring harmonica.Spring pulsePos float64 @@ -69,9 +81,13 @@ type Model struct { sites []models.Site alerts []models.AlertConfig 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.SetContent("Waiting for logs...") z := zone.New() @@ -81,8 +97,11 @@ func InitialModel(isAdmin bool) Model { logViewport: vpLogs, maxTableRows: 5, isAdmin: isAdmin, + store: s, + engine: eng, zones: z, 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) { 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.) if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser { 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" { m.huhForm = nil m.state = stateDashboard - if m.currentTab == 3 { + if m.currentTab == 4 { m.state = stateUsers } return m, nil @@ -126,6 +184,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + m.termWidth = msg.Width + m.termHeight = msg.Height m.maxTableRows = msg.Height - 12 if 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 { listLen = len(m.alerts) } else if m.currentTab == 3 { + listLen = len(m.nodes) + } else if m.currentTab == 4 { listLen = len(m.users) } if msg.Button == tea.MouseButtonWheelUp { @@ -188,11 +250,54 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { + case stateDetail: + switch msg.String() { + case "i", "esc": + m.state = stateDashboard + case "q": + return m, tea.Quit + } + return m, nil case stateDashboard, stateLogs, stateUsers: switch msg.String() { case "q": return m, tea.Quit + case "/": + if m.currentTab == 0 { + m.filterMode = true + return m, nil + } case "tab": m.switchTab(m.currentTab + 1) case "pgup", "pgdown": @@ -218,6 +323,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { max = len(m.alerts) - 1 } if m.currentTab == 3 { + max = len(m.nodes) - 1 + } + if m.currentTab == 4 { max = len(m.users) - 1 } if m.cursor < max { @@ -236,7 +344,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if m.currentTab == 1 { m.state = stateFormAlert return m, m.initAlertHuhForm() - } else if m.currentTab == 3 && m.isAdmin { + } else if m.currentTab == 4 && m.isAdmin { m.state = stateFormUser return m, m.initUserHuhForm() } @@ -250,25 +358,46 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editID = m.alerts[m.cursor].ID m.state = stateFormAlert 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.state = stateFormUser return m, m.initUserHuhForm() } - case "d", "backspace": - if m.currentTab == 1 && len(m.alerts) > 0 { - store.Get().DeleteAlert(m.alerts[m.cursor].ID) - m.adjustCursor(len(m.alerts) - 1) - } 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) + case " ": + if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" { + gid := m.sites[m.cursor].ID + m.collapsed[gid] = !m.collapsed[gid] + 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 } - m.refreshData() } } } @@ -276,11 +405,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - maxTabs := 3 - if !m.isAdmin { - maxTabs = 2 + tabCount := 4 + if m.isAdmin { + 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) { m.switchTab(i) 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 if 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) { - maxTabs := 2 + maxTabs := 3 if m.isAdmin { - maxTabs = 3 + maxTabs = 4 } if idx > maxTabs { idx = 0 @@ -343,7 +472,7 @@ func (m *Model) switchTab(idx int) { switch idx { case 2: m.state = stateLogs - case 3: + case 4: m.state = stateUsers default: m.state = stateDashboard @@ -363,27 +492,78 @@ func (m *Model) adjustCursor(newLen int) { } func (m *Model) refreshData() { - monitor.Mutex.RLock() - var sites []models.Site - for _, s := range monitor.LiveState { - sites = append(sites, s) - } - 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 { - m.users = store.Get().GetAllUsers() + allSites := m.engine.GetAllSites() + + var groups, ungrouped []models.Site + children := make(map[int][]models.Site) + for _, s := range allSites { + if s.Type == "group" { + groups = append(groups, s) + } else if s.ParentID > 0 { + children[s.ParentID] = append(children[s.ParentID], s) + } else { + ungrouped = append(ungrouped, s) } } - m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) + 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 + } + if m.isAdmin { + if users, err := m.store.GetAllUsers(); err == nil { + m.users = users + } + } + 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() { - if store.Get() == nil { - return - } switch m.state { case stateFormSite: if m.siteFormData != nil { @@ -406,12 +586,39 @@ func (m Model) pulseIndicator() string { if 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]) } func (m Model) View() string { 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: if m.huhForm != nil { 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 "" + case stateDetail: + return m.viewDetailPanel() default: return m.zones.Scan(m.viewDashboard()) } } 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 { tabs = append(tabs, "Users") } @@ -471,16 +710,70 @@ func (m Model) viewDashboard() string { case 2: content = m.viewLogsTab() case 3: + content = m.viewNodesTab() + case 4: if m.isAdmin { content = m.viewUsersTab() } } - footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") - if m.currentTab == 3 { - footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + upCount := len(m.sites) - downCount + var upStr string + 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 {