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