From 02f0a39d973eb2434e66ec521be4b49dc8058bc2 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 11:05:10 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20commit=20=E2=80=94=20uptime?= =?UTF-8?q?=20monitor=20(forked=20from=20go-upkeep)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go-based uptime monitor with SQLite/Postgres storage, TUI dashboard, SSH server, alerting, and clustering support. --- .dockerignore | 3 + .github/workflows/docker.yml | 45 ++++ .gitignore | 39 ++++ Dockerfile | 28 +++ LICENSE | 21 ++ README.md | 89 ++++++++ cmd/goupkeep/main.go | 189 ++++++++++++++++ docker-compose.cluster.yml | 72 ++++++ docker-compose.dev.yml | 36 +++ go.mod | 54 +++++ go.sum | 120 ++++++++++ internal/alert/alert.go | 84 +++++++ internal/cluster/cluster.go | 69 ++++++ internal/models/models.go | 47 ++++ internal/monitor/history.go | 70 ++++++ internal/monitor/monitor.go | 315 ++++++++++++++++++++++++++ internal/server/server.go | 153 +++++++++++++ internal/store/postgres.go | 163 ++++++++++++++ internal/store/sqlite.go | 175 ++++++++++++++ internal/store/store.go | 41 ++++ internal/tui/tab_alerts.go | 155 +++++++++++++ internal/tui/tab_logs.go | 5 + internal/tui/tab_sites.go | 363 +++++++++++++++++++++++++++++ internal/tui/tab_users.go | 72 ++++++ internal/tui/tui.go | 426 +++++++++++++++++++++++++++++++++++ 25 files changed, 2834 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/goupkeep/main.go create mode 100644 docker-compose.cluster.yml create mode 100644 docker-compose.dev.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/alert/alert.go create mode 100644 internal/cluster/cluster.go create mode 100644 internal/models/models.go create mode 100644 internal/monitor/history.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/server/server.go create mode 100644 internal/store/postgres.go create mode 100644 internal/store/sqlite.go create mode 100644 internal/store/store.go create mode 100644 internal/tui/tab_alerts.go create mode 100644 internal/tui/tab_logs.go create mode 100644 internal/tui/tab_sites.go create mode 100644 internal/tui/tab_users.go create mode 100644 internal/tui/tui.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d0e38d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +tmp/ +vendor/ \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..a8b1591 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,45 @@ +name: Publish Release + +on: + push: + tags: + - 'v*' + +jobs: + push_to_registry: + name: Build and Push Docker Image + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/go-upkeep + tags: | + # This turns git tag "v1.0.0" into docker tag "1.0.0" + type=semver,pattern={{version}} + # This updates the "latest" tag to this version + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d66a70e --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Created by https://www.toptal.com/developers/gitignore/api/go +# Edit at https://www.toptal.com/developers/gitignore?templates=go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# End of https://www.toptal.com/developers/gitignore/api/go + +/goupkeep +upkeep.db + +.ssh + +authorized_keys + +tmp + +# Old repo +/go-upkeep/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ffcac62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# --- Stage 1: Builder --- +FROM golang:alpine AS builder +RUN apk add --no-cache gcc musl-dev +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ENV CGO_ENABLED=1 +RUN go build -ldflags="-s -w" -o go-upkeep ./cmd/goupkeep/main.go + +# --- Stage 2: Runner --- +FROM alpine:latest +WORKDIR /app +RUN apk add --no-cache ca-certificates openssh-client +RUN mkdir /data + +COPY --from=builder /app/go-upkeep . + +# Set Default Configuration via ENV +# Docker users can override these in docker-compose.yml +ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true +ENV UPKEEP_DB_TYPE=sqlite +ENV UPKEEP_DB_DSN=/data/upkeep.db +ENV UPKEEP_KEYS=/data/authorized_keys +ENV UPKEEP_PORT=23234 + +EXPOSE 23234 +CMD ["./go-upkeep"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ddf0518 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Roman Dvoล™รกk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7424e1 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Go-Upkeep + +![Go Version](https://img.shields.io/badge/go-1.23-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Docker](https://img.shields.io/docker/pulls/rdgames1000/go-upkeep) + +**Go-Upkeep** is a self-hosted infrastructure monitor with a retro-futuristic TUI accessible via SSH. It supports High Availability, Push Monitoring, and Alerting. + +* ๐ŸŒ **Full Documentation:** [goupkeep.org/docs](https://goupkeep.org/docs) +* ๐Ÿณ **Docker Hub:** [rdgames1000/go-upkeep](https://hub.docker.com/r/rdgames1000/go-upkeep) + +--- + +## ๐Ÿš€ Key Features + +* **SSH Dashboard**: Zero-install client. Manage monitors via `ssh -p 23234 your-server`. +* **Protocols**: + * **HTTP/S**: Active polling with SSL certificate expiration tracking. + * **PUSH**: Heartbeat endpoints for cron jobs/backup scripts. +* **High Availability**: Leader/Follower clustering with automatic failover. +* **Alerting**: Native support for Discord, Slack, Email (SMTP), and Webhooks. +* **Backends**: SQLite (default) or PostgreSQL (production). + +--- + +## ๐Ÿ› ๏ธ Quick Start (Local Dev) + +**Option A: Native Go (Fastest)** +```bash +go mod tidy +go run cmd/goupkeep/main.go +# Connect: ssh -p 23234 localhost +``` + +**Option B: Docker Compose (Full Stack)** +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +--- + +## ๐Ÿ“ฆ Production Deployment + +For critical infrastructure, we recommend Docker Compose. + +### 1. The Compose File +Create `docker-compose.yml`: + +```yaml +services: + monitor: + image: rdgames1000/go-upkeep:latest + container_name: go-upkeep + restart: unless-stopped + stdin_open: true # Required for initial setup console + tty: true + ports: + - "23234:23234" # SSH + - "8080:8080" # HTTP (Status Page & Push) + volumes: + - ./data:/data + - ./ssh_keys:/app/.ssh + environment: + - UPKEEP_DB_TYPE=sqlite + - UPKEEP_DB_DSN=/data/upkeep.db + - UPKEEP_STATUS_ENABLED=true + - UPKEEP_CLUSTER_SECRET=ChangeMeToSomethingSecure +``` + +### 2. Initial Setup (Identity Management) +**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` +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 +Connect using your standard SSH client: +```bash +ssh -p 23234 your-server-ip +``` + +For advanced setups (Postgres, Clustering, Migration), please consult the [Official Documentation](https://goupkeep.org/docs). + +## ๐Ÿ“„ License +MIT License. \ No newline at end of file diff --git a/cmd/goupkeep/main.go b/cmd/goupkeep/main.go new file mode 100644 index 0000000..250cff6 --- /dev/null +++ b/cmd/goupkeep/main.go @@ -0,0 +1,189 @@ +package main + +import ( + "flag" + "fmt" + "go-upkeep/internal/cluster" + "go-upkeep/internal/monitor" + "go-upkeep/internal/server" + "go-upkeep/internal/store" + "go-upkeep/internal/tui" + "io" + "log" + "os" + "os/signal" + "strconv" + "syscall" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/ssh" + "github.com/charmbracelet/wish" + bm "github.com/charmbracelet/wish/bubbletea" + "github.com/mattn/go-isatty" +) + +func main() { + log.SetOutput(io.Discard) + + portVal := 23234 + dbType := "sqlite" + dbDSN := "upkeep.db" + httpPort := 8080 + enableStatus := false + statusTitle := "System Status" + clusterMode := "leader" + clusterPeer := "" + clusterKey := "" + + if v := os.Getenv("UPKEEP_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + portVal = p + } + } + if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" { + dbType = v + } + if v := os.Getenv("UPKEEP_DB_DSN"); v != "" { + dbDSN = v + } + if v := os.Getenv("UPKEEP_HTTP_PORT"); v != "" { + if p, err := strconv.Atoi(v); err == nil { + httpPort = p + } + } + if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" { + enableStatus = true + } + if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { + statusTitle = v + } + + if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { + clusterMode = v + } + if v := os.Getenv("UPKEEP_PEER_URL"); v != "" { + clusterPeer = v + } + if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { + clusterKey = v + } + + port := flag.Int("port", portVal, "SSH Port") + flagDBType := flag.String("db-type", dbType, "Database type") + flagDSN := flag.String("dsn", dbDSN, "Database DSN") + demo := flag.Bool("demo", false, "Seed demo data") + flag.Parse() + + var s store.Store + if *flagDBType == "postgres" { + s = &store.PostgresStore{ConnStr: *flagDSN} + fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) + } else { + s = &store.SQLiteStore{DBPath: *flagDSN} + fmt.Printf("Using SQLite: %s\n", *flagDSN) + } + + if err := s.Init(); err != nil { + fmt.Printf("Database Init Error: %v\n", err) + os.Exit(1) + } + store.SetGlobal(s) + + if *demo { + seedDemoData(s) + } + + monitor.StartEngine() + + server.Start(server.ServerConfig{ + Port: httpPort, + EnableStatus: enableStatus, + Title: statusTitle, + ClusterKey: clusterKey, + }) + + cluster.Start(cluster.Config{ + Mode: clusterMode, + PeerURL: clusterPeer, + SharedKey: clusterKey, + }) + + startSSHServer(*port) + + if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { + p := tea.NewProgram(tui.InitialModel(true), tea.WithAltScreen(), tea.WithMouseCellMotion()) + if _, err := p.Run(); err != nil { + fmt.Printf("Error: %v\n", err) + } + } else { + fmt.Println("Go-Upkeep running in HEADLESS mode") + done := make(chan os.Signal, 1) + signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + <-done + fmt.Println("Shutting down...") + } +} + +func startSSHServer(port int) { + s, err := wish.NewServer( + wish.WithAddress(fmt.Sprintf(":%d", port)), + wish.WithHostKeyPath(".ssh/id_ed25519"), + wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + return isKeyAllowed(key) + }), + wish.WithMiddleware( + bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { + return tui.InitialModel(false), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()} + }), + ), + ) + if err != nil { + fmt.Printf("SSH server error: %v\n", err) + return + } + go func() { s.ListenAndServe() }() +} + +func seedDemoData(s store.Store) { + if existing := s.GetSites(); len(existing) > 0 { + return + } + fmt.Println("Seeding demo data...") + + s.AddAlert("Discord Ops", "discord", map[string]string{"url": "https://discord.com/api/webhooks/demo/token"}) + s.AddAlert("Slack Infra", "slack", map[string]string{"url": "https://hooks.slack.com/services/DEMO/WEBHOOK"}) + s.AddAlert("Email Oncall", "email", map[string]string{ + "host": "smtp.gmail.com", "port": "587", + "user": "oncall@example.com", "pass": "hunter2", + "from": "oncall@example.com", "to": "team@example.com", + }) + + alerts := s.GetAllAlerts() + alertID := 0 + if len(alerts) > 0 { + alertID = alerts[0].ID + } + + s.AddSite("Google", "https://www.google.com", "http", 30, alertID, true, 14, 2) + s.AddSite("GitHub", "https://github.com", "http", 30, alertID, true, 7, 3) + s.AddSite("Cloudflare DNS", "https://1.1.1.1", "http", 60, alertID, false, 7, 1) + s.AddSite("JSON Placeholder", "https://jsonplaceholder.typicode.com/posts/1", "http", 45, alertID, false, 7, 2) + s.AddSite("Nonexistent Site", "https://this-domain-does-not-exist-12345.com", "http", 30, alertID, false, 7, 3) + s.AddSite("Bad Port", "https://localhost:19999", "http", 30, 0, false, 7, 1) + s.AddSite("Backup Cron", "", "push", 300, alertID, false, 7, 0) + s.AddSite("DB Healthcheck", "", "push", 120, alertID, false, 7, 0) +} + +func isKeyAllowed(incomingKey ssh.PublicKey) bool { + users := store.Get().GetAllUsers() + for _, u := range users { + allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) + if err != nil { + continue + } + if ssh.KeysEqual(allowedKey, incomingKey) { + return true + } + } + return false +} diff --git a/docker-compose.cluster.yml b/docker-compose.cluster.yml new file mode 100644 index 0000000..b6c6c7a --- /dev/null +++ b/docker-compose.cluster.yml @@ -0,0 +1,72 @@ +services: + # ------------------------- + # LEADER NODE + # ------------------------- + leader: + build: . + container_name: upkeep-leader + ports: + - "23234:23234" # SSH + - "8080:8080" # HTTP + environment: + - UPKEEP_DB_TYPE=postgres + # Note: Port 5432 is correct here because we are talking INSIDE the network + - UPKEEP_DB_DSN=postgres://devuser:devpass@leader-db:5432/upkeep_dev?sslmode=disable + - UPKEEP_HTTP_PORT=8080 + - UPKEEP_STATUS_ENABLED=true + - UPKEEP_STATUS_TITLE=Leader Node + + # Cluster Config + - UPKEEP_CLUSTER_MODE=leader + - UPKEEP_CLUSTER_SECRET=mysecret + depends_on: + - leader-db + stdin_open: true + tty: true + + leader-db: + image: postgres:15-alpine + container_name: upkeep-leader-db + environment: + POSTGRES_USER: devuser + POSTGRES_PASSWORD: devpass + POSTGRES_DB: upkeep_dev + volumes: + - ./tmp/leader-data:/var/lib/postgresql/data + + # ------------------------- + # FOLLOWER NODE + # ------------------------- + follower: + build: . + container_name: upkeep-follower + ports: + - "23233:23234" # SSH (Mapped to different host port) + - "8081:8080" # HTTP (Mapped to different host port) + environment: + - UPKEEP_DB_TYPE=postgres + # Connects to its OWN database + - UPKEEP_DB_DSN=postgres://devuser:devpass@follower-db:5432/upkeep_dev?sslmode=disable + - UPKEEP_HTTP_PORT=8080 + - UPKEEP_STATUS_ENABLED=true + - UPKEEP_STATUS_TITLE=Follower Node + + # Cluster Config + - UPKEEP_CLUSTER_MODE=follower + - UPKEEP_CLUSTER_SECRET=mysecret + # IMPORTANT: Uses the Service Name "leader" to connect internally + - UPKEEP_PEER_URL=http://leader:8080 + depends_on: + - follower-db + stdin_open: true + tty: true + + follower-db: + image: postgres:15-alpine + container_name: upkeep-follower-db + environment: + POSTGRES_USER: devuser + POSTGRES_PASSWORD: devpass + POSTGRES_DB: upkeep_dev + volumes: + - ./tmp/follower-data:/var/lib/postgresql/data \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..dc08057 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,36 @@ +services: + # The Application + app: + build: + context: . + dockerfile: Dockerfile + container_name: upkeep-dev + ports: + - "23234:23234" # SSH Access + - "8080:8080" # HTTP (Push Monitors + Status Page) + environment: + # --- Database Configuration (Postgres) --- + - UPKEEP_DB_TYPE=postgres + - UPKEEP_DB_DSN=postgres://devuser:devpass@postgres:5432/upkeep_dev?sslmode=disable + + # --- Web Server Configuration (Phase 4) --- + - UPKEEP_HTTP_PORT=8080 + - UPKEEP_STATUS_ENABLED=true + - UPKEEP_STATUS_TITLE=Dev Infrastructure Status + depends_on: + - postgres + stdin_open: true # Required for 'docker attach' (Local Admin Console) + tty: true # Required for TUI rendering + + # The Database + postgres: + image: postgres:15-alpine + container_name: upkeep-postgres + environment: + POSTGRES_USER: devuser + POSTGRES_PASSWORD: devpass + POSTGRES_DB: upkeep_dev + ports: + - "5432:5432" # Expose for external DB tools (DBeaver, etc.) + volumes: + - ./tmp/pgdata:/var/lib/postgresql/data \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5048105 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module go-upkeep + +go 1.24.4 + +require ( + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 + github.com/charmbracelet/wish v1.4.7 + github.com/lib/pq v1.11.1 + github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-sqlite3 v1.14.33 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.3.1 // indirect + github.com/charmbracelet/harmonica v0.2.0 // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/keygen v0.5.3 // indirect + github.com/charmbracelet/log v0.4.2 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/conpty v0.1.0 // indirect + github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/input v0.3.4 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.0 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lrstanley/bubblezone v1.0.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..832538c --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= +github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= +github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= +github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc= +github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE= +github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc= +github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= +github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= +github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= +github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= +github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lrstanley/bubblezone v1.0.0 h1:bIpUaBilD42rAQwlg/4u5aTqVAt6DSRKYZuSdmkr8UA= +github.com/lrstanley/bubblezone v1.0.0/go.mod h1:kcTekA8HE/0Ll2bWzqHlhA2c513KDNLW7uDfDP4Mly8= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/alert/alert.go b/internal/alert/alert.go new file mode 100644 index 0000000..0933de2 --- /dev/null +++ b/internal/alert/alert.go @@ -0,0 +1,84 @@ +package alert + +import ( + "bytes" + "encoding/json" + "fmt" + "go-upkeep/internal/models" + "net/http" + "net/smtp" +) + +type Provider interface { + Send(title, message string) error +} + +func GetProvider(cfg models.AlertConfig) Provider { + switch cfg.Type { + case "discord": + return &DiscordProvider{URL: cfg.Settings["url"]} + case "slack": + return &SlackProvider{URL: cfg.Settings["url"]} + case "webhook": + // Generic Webhook + return &WebhookProvider{URL: cfg.Settings["url"]} + case "email": + port := "25" + if p, ok := cfg.Settings["port"]; ok { port = p } + return &EmailProvider{ + Host: cfg.Settings["host"], + Port: port, + User: cfg.Settings["user"], + Pass: cfg.Settings["pass"], + To: cfg.Settings["to"], + From: cfg.Settings["from"], + } + default: + return nil + } +} + +// --- DISCORD --- +type DiscordProvider struct{ URL string } +func (d *DiscordProvider) Send(title, message string) error { + payload := map[string]string{"content": fmt.Sprintf("**%s**\n%s", title, message)} + jsonValue, _ := json.Marshal(payload) + _, err := http.Post(d.URL, "application/json", bytes.NewBuffer(jsonValue)) + return err +} + +// --- 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) + _, err := http.Post(s.URL, "application/json", bytes.NewBuffer(jsonValue)) + return err +} + +// --- GENERIC WEBHOOK --- +type WebhookProvider struct{ URL string } +func (w *WebhookProvider) Send(title, message string) error { + // Sends a standard JSON payload + payload := map[string]string{ + "title": title, + "message": message, + "status": "alert", + } + jsonValue, _ := json.Marshal(payload) + _, err := http.Post(w.URL, "application/json", bytes.NewBuffer(jsonValue)) + return err +} + +// --- EMAIL --- +type EmailProvider struct { + Host, Port, User, Pass, To, From string +} +func (e *EmailProvider) Send(title, message string) error { + auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) + msg := []byte("To: " + e.To + "\r\n" + + "Subject: Go-Upkeep: " + title + "\r\n" + + "\r\n" + + message + "\r\n") + return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) +} \ No newline at end of file diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go new file mode 100644 index 0000000..205039f --- /dev/null +++ b/internal/cluster/cluster.go @@ -0,0 +1,69 @@ +package cluster + +import ( + "fmt" + "go-upkeep/internal/monitor" + "net/http" + "time" +) + +type Config struct { + Mode string // "leader" or "follower" + PeerURL string // URL of the Leader (e.g., http://primary:8080) + SharedKey string // Security Key +} + +func Start(cfg Config) { + if cfg.Mode == "leader" { + fmt.Println("Cluster: Running as LEADER (Active)") + monitor.SetEngineActive(true) + return + } + + if cfg.Mode == "follower" { + fmt.Println("Cluster: Running as FOLLOWER (Passive)") + monitor.SetEngineActive(false) // Start passive + go runFollowerLoop(cfg) + } +} + +func runFollowerLoop(cfg Config) { + client := http.Client{Timeout: 2 * time.Second} + + // Failover Configuration + failures := 0 + threshold := 3 + + for { + time.Sleep(5 * time.Second) + + req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil) + if cfg.SharedKey != "" { + req.Header.Set("X-Upkeep-Secret", cfg.SharedKey) + } + + resp, err := client.Do(req) + isLeaderHealthy := false + + if err == nil && resp.StatusCode == 200 { + isLeaderHealthy = true + resp.Body.Close() + } + + if isLeaderHealthy { + failures = 0 + if monitor.IsEngineActive() { + // Leader is back, yield + monitor.SetEngineActive(false) + monitor.AddLog("Cluster: Leader detected. Switching to PASSIVE.") + } + } else { + failures++ + // If failures exceed threshold, take over + if failures >= threshold && !monitor.IsEngineActive() { + monitor.SetEngineActive(true) + monitor.AddLog("Cluster: Leader Unreachable. Switching to ACTIVE.") + } + } + } +} \ No newline at end of file diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..97354e5 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,47 @@ +package models + +import "time" + +type Site struct { + ID int + Name string + URL string + Type string // "http" or "push" + Token string // Secure Token + Interval int + AlertID int + CheckSSL bool + ExpiryThreshold int + + MaxRetries int + FailureCount int + + Status string + StatusCode int + Latency time.Duration + CertExpiry time.Time + HasSSL bool + LastCheck time.Time + SentSSLWarning bool +} + +type AlertConfig struct { + ID int + Name string + Type string + Settings map[string]string +} + +type User struct { + ID int + Username string + PublicKey string + Role string +} + +// Phase 5: Backup Structure +type Backup struct { + Sites []Site `json:"sites"` + Alerts []AlertConfig `json:"alerts"` + Users []User `json:"users"` +} \ No newline at end of file diff --git a/internal/monitor/history.go b/internal/monitor/history.go new file mode 100644 index 0000000..c6fbcbe --- /dev/null +++ b/internal/monitor/history.go @@ -0,0 +1,70 @@ +package monitor + +import ( + "sync" + "time" +) + +const maxHistoryLen = 30 + +type SiteHistory struct { + Latencies []time.Duration + Statuses []bool + TotalChecks int + UpChecks int +} + +var ( + histories = make(map[int]*SiteHistory) + historyMu sync.RWMutex +) + +func RecordCheck(siteID int, latency time.Duration, isUp bool) { + historyMu.Lock() + defer historyMu.Unlock() + + h, ok := histories[siteID] + if !ok { + h = &SiteHistory{} + histories[siteID] = h + } + + h.TotalChecks++ + if isUp { + h.UpChecks++ + } + + h.Latencies = append(h.Latencies, latency) + if len(h.Latencies) > maxHistoryLen { + h.Latencies = h.Latencies[len(h.Latencies)-maxHistoryLen:] + } + + h.Statuses = append(h.Statuses, isUp) + if len(h.Statuses) > maxHistoryLen { + h.Statuses = h.Statuses[len(h.Statuses)-maxHistoryLen:] + } +} + +func GetHistory(siteID int) (SiteHistory, bool) { + historyMu.RLock() + defer historyMu.RUnlock() + h, ok := histories[siteID] + if !ok { + return SiteHistory{}, false + } + cp := SiteHistory{ + TotalChecks: h.TotalChecks, + UpChecks: h.UpChecks, + Latencies: make([]time.Duration, len(h.Latencies)), + Statuses: make([]bool, len(h.Statuses)), + } + copy(cp.Latencies, h.Latencies) + copy(cp.Statuses, h.Statuses) + return cp, true +} + +func RemoveHistory(siteID int) { + historyMu.Lock() + defer historyMu.Unlock() + delete(histories, siteID) +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..5b4a5c8 --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,315 @@ +package monitor + +import ( + "crypto/tls" + "fmt" + "go-upkeep/internal/alert" + "go-upkeep/internal/models" + "go-upkeep/internal/store" + "net/http" + "sync" + "time" +) + +// --- LOGGING --- +var ( + LogStore []string + LogMutex sync.RWMutex +) + +func AddLog(msg string) { + LogMutex.Lock() + defer LogMutex.Unlock() + ts := time.Now().Format("15:04:05") + entry := fmt.Sprintf("[%s] %s", ts, msg) + LogStore = append([]string{entry}, LogStore...) + if len(LogStore) > 100 { + LogStore = LogStore[:100] + } +} + +func GetLogs() []string { + LogMutex.RLock() + defer LogMutex.RUnlock() + logs := make([]string, len(LogStore)) + copy(logs, LogStore) + return logs +} + +// --- ENGINE --- + +var ( + LiveState = make(map[int]models.Site) + Mutex sync.RWMutex + + // Global Switch for HA + isActive = true + activeMutex sync.RWMutex +) + +func SetEngineActive(active bool) { + activeMutex.Lock() + defer activeMutex.Unlock() + if isActive != active { + isActive = active + status := "RESUMED (Active)" + if !active { + status = "PAUSED (Passive)" + } + AddLog(fmt.Sprintf("Engine %s", status)) + } +} + +func IsEngineActive() bool { + activeMutex.RLock() + defer activeMutex.RUnlock() + return isActive +} + +func RecordHeartbeat(token string) bool { + if !IsEngineActive() { + return false + } // Only Leader accepts Push + + Mutex.Lock() + defer Mutex.Unlock() + var targetID int = -1 + for id, s := range LiveState { + if s.Type == "push" && s.Token == token { + targetID = id + break + } + } + if targetID == -1 { + return false + } + + site := LiveState[targetID] + site.LastCheck = time.Now() + wasDown := site.Status == "DOWN" + site.Status = "UP" + site.FailureCount = 0 + site.Latency = 0 + LiveState[targetID] = site + + if wasDown { + AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) + triggerAlert(site.AlertID, "โœ… RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) + } + return true +} + +func StartEngine() { + go func() { + for { + s_instance := store.Get() + if s_instance == nil { + time.Sleep(1 * time.Second) + continue + } + + sites := s_instance.GetSites() + for _, s := range sites { + Mutex.RLock() + _, exists := LiveState[s.ID] + Mutex.RUnlock() + if !exists { + Mutex.Lock() + s.Status = "PENDING" + if s.Type == "push" { + s.LastCheck = time.Now() + } + LiveState[s.ID] = s + Mutex.Unlock() + go monitorRoutine(s.ID) + } + } + time.Sleep(5 * time.Second) + } + }() +} + +func UpdateSiteConfig(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { + Mutex.Lock() + defer Mutex.Unlock() + if s, ok := LiveState[id]; ok { + s.Name = name + s.URL = url + s.Type = sType + s.Interval = interval + s.AlertID = alertID + s.CheckSSL = checkSSL + s.ExpiryThreshold = threshold + s.MaxRetries = retries + LiveState[id] = s + } +} + +func RemoveSite(id int) { + Mutex.Lock() + delete(LiveState, id) + Mutex.Unlock() + RemoveHistory(id) +} + +func monitorRoutine(id int) { + checkByID(id) + for { + // If paused, just sleep loop to keep goroutine alive but idle + if !IsEngineActive() { + time.Sleep(5 * time.Second) + continue + } + + Mutex.RLock() + site, exists := LiveState[id] + Mutex.RUnlock() + if !exists { + return + } + + interval := site.Interval + if interval < 5 { + interval = 5 + } + time.Sleep(time.Duration(interval) * time.Second) + checkByID(id) + } +} + +func checkByID(id int) { + if !IsEngineActive() { + return + } + + Mutex.RLock() + site, exists := LiveState[id] + Mutex.RUnlock() + if !exists { + return + } + if site.Type == "http" { + checkHTTP(site) + } else { + checkPush(site) + } +} + +func checkPush(site models.Site) { + deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second) + if time.Now().After(deadline) { + handleStatusChange(site, "DOWN", 0, 0) + } else { + if site.Status != "UP" { + handleStatusChange(site, "UP", 200, 0) + } + } +} + +func checkHTTP(site models.Site) { + start := time.Now() + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + 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 + } + + newState := site + newState.StatusCode = code + + if site.Status == "UP" && rawStatus != "UP" { + newState.FailureCount++ + if newState.FailureCount > site.MaxRetries { + newState.Status = rawStatus + newState.FailureCount = site.MaxRetries + 1 + AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name)) + } else { + AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries)) + } + } else if rawStatus == "UP" { + newState.FailureCount = 0 + newState.Status = "UP" + } else { + newState.Status = rawStatus + newState.FailureCount = site.MaxRetries + 1 + } + + if site.Type == "http" && site.CheckSSL && site.HasSSL { + daysLeft := int(time.Until(site.CertExpiry).Hours() / 24) + if daysLeft <= site.ExpiryThreshold && !site.SentSSLWarning && rawStatus != "SSL EXP" { + triggerAlert(site.AlertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", site.Name, daysLeft)) + newState.SentSSLWarning = true + } else if daysLeft > site.ExpiryThreshold { + newState.SentSSLWarning = false + } + } + + Mutex.Lock() + if _, ok := LiveState[site.ID]; ok { + LiveState[site.ID] = newState + } + Mutex.Unlock() + + RecordCheck(site.ID, latency, rawStatus == "UP") + + isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } + if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" { + msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus) + if site.Type == "push" { + msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name) + } + triggerAlert(site.AlertID, "๐Ÿšจ ALERT", msg) + } + if isBroken(site.Status) && newState.Status == "UP" { + triggerAlert(site.AlertID, "โœ… RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) + } +} + +func triggerAlert(alertID int, title, message string) { + s_instance := store.Get() + if s_instance == nil { + return + } + cfg, ok := s_instance.GetAlert(alertID) + if !ok { + return + } + provider := alert.GetProvider(cfg) + if provider != nil { + go func() { provider.Send(title, message) }() + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..d65f224 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,153 @@ +package server + +import ( + "encoding/json" + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "go-upkeep/internal/store" + "html/template" + "net/http" + "sort" +) + +type ServerConfig struct { + Port int + EnableStatus bool + Title string + ClusterKey string // Shared Secret for Security +} + +func Start(cfg ServerConfig) { + mux := http.NewServeMux() + + // 1. Push Heartbeat + mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { http.Error(w, "Missing token", 400); return } + if monitor.RecordHeartbeat(token) { + w.WriteHeader(http.StatusOK); w.Write([]byte("OK")) + } else { + http.Error(w, "Invalid Token", 404) + } + }) + + // 2. Health Check (For Cluster Follower) + mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { + if cfg.ClusterKey != "" && r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { + http.Error(w, "Unauthorized", 401) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // 3. Config Export + mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { + if cfg.ClusterKey == "" || r.Header.Get("X-Upkeep-Secret") != cfg.ClusterKey { + http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", 401) + return + } + data := store.Get().ExportData() + json.NewEncoder(w).Encode(data) + }) + + // 4. Config Import + mux.HandleFunc("/api/backup/import", 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 data models.Backup + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "Invalid JSON", 400) + return + } + if err := store.Get().ImportData(data); err != nil { + http.Error(w, "Import Failed: "+err.Error(), 500) + return + } + w.Write([]byte("Import Successful")) + }) + + // 5. Status Page + if cfg.EnableStatus { + mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title) }) + mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { + monitor.Mutex.RLock(); defer monitor.Mutex.RUnlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(monitor.LiveState) + }) + } + + go func() { + addr := fmt.Sprintf(":%d", cfg.Port) + fmt.Printf("HTTP Server listening on %s\n", addr) + http.ListenAndServe(addr, mux) + }() +} + +func renderStatusPage(w http.ResponseWriter, title string) { + monitor.Mutex.RLock() + var sites []models.Site + for _, s := range monitor.LiveState { + sites = append(sites, s) + } + monitor.Mutex.RUnlock() + + sort.Slice(sites, func(i, j int) bool { + if sites[i].Status != sites[j].Status { + if sites[i].Status == "DOWN" { return true } + if sites[j].Status == "DOWN" { return false } + } + return sites[i].Name < sites[j].Name + }) + + const tpl = ` + + + + {{.Title}} + + + + + +
+

{{.Title}}

+ {{range .Sites}} +
+
+
{{.Name}}
+
{{.Type}} | {{if eq .Type "http"}}{{.URL}}{{else}}Heartbeat Monitor{{end}}
+
Last Check: {{.LastCheck.Format "15:04:05"}}
+
+
{{.Status}}
+
+ {{end}} +
Powered by Go-Upkeep
+
+ + + ` + + t, _ := template.New("status").Parse(tpl) + data := struct { Title string; Sites []models.Site }{Title: title, Sites: sites} + t.Execute(w, data) +} \ No newline at end of file diff --git a/internal/store/postgres.go b/internal/store/postgres.go new file mode 100644 index 0000000..9db92d0 --- /dev/null +++ b/internal/store/postgres.go @@ -0,0 +1,163 @@ +package store + +import ( + "database/sql" + "encoding/json" + "go-upkeep/internal/models" + + _ "github.com/lib/pq" +) + +type PostgresStore struct { + ConnStr string + db *sql.DB +} + +func (p *PostgresStore) Init() error { + var err error + p.db, err = sql.Open("postgres", p.ConnStr) + if err != nil { return err } + + queries := []string{ + `CREATE TABLE IF NOT EXISTS alerts ( + id SERIAL PRIMARY KEY, + name TEXT, + type TEXT, + settings TEXT + );`, + `CREATE TABLE IF NOT EXISTS sites ( + id SERIAL PRIMARY KEY, + name TEXT DEFAULT 'New Monitor', + url TEXT, + type TEXT DEFAULT 'http', + token TEXT, + interval INTEGER, + alert_id INTEGER, + check_ssl BOOLEAN DEFAULT FALSE, + threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0 + );`, + `CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL, + public_key TEXT NOT NULL, + role TEXT DEFAULT 'user' + );`, + } + for _, q := range queries { + if _, err := p.db.Exec(q); err != nil { return err } + } + 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 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) + sites = append(sites, s) + } + return sites +} +func (p *PostgresStore) AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { + token := "" + if sType == "push" { token = generateToken() } + p.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", name, url, sType, token, interval, alertID, checkSSL, threshold, retries) +} +func (p *PostgresStore) UpdateSite(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { + var existingToken string + p.db.QueryRow("SELECT token FROM sites WHERE id=$1", id).Scan(&existingToken) + if sType == "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 WHERE id=$10", name, url, sType, existingToken, interval, alertID, checkSSL, threshold, retries, 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) 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 { + tx, err := p.db.Begin() + if err != nil { return err } + + tx.Exec("TRUNCATE TABLE sites RESTART IDENTITY CASCADE") + tx.Exec("TRUNCATE TABLE alerts RESTART IDENTITY CASCADE") + tx.Exec("TRUNCATE TABLE users RESTART IDENTITY CASCADE") + + for _, u := range data.Users { + tx.Exec("INSERT INTO users (username, public_key, role) VALUES ($1, $2, $3)", u.Username, u.PublicKey, u.Role) + } + for _, a := range data.Alerts { + jsonBytes, _ := json.Marshal(a.Settings) + tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES ($1, $2, $3, $4)", a.ID, a.Name, a.Type, string(jsonBytes)) + } + for _, st := range data.Sites { + tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries) + } + + tx.Exec("SELECT setval('sites_id_seq', (SELECT MAX(id) FROM sites))") + tx.Exec("SELECT setval('alerts_id_seq', (SELECT MAX(id) FROM alerts))") + tx.Exec("SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))") + + return tx.Commit() +} \ No newline at end of file diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go new file mode 100644 index 0000000..1ba90ed --- /dev/null +++ b/internal/store/sqlite.go @@ -0,0 +1,175 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "go-upkeep/internal/models" + + _ "github.com/mattn/go-sqlite3" +) + +type SQLiteStore struct { + DBPath string + db *sql.DB +} + +func (s *SQLiteStore) Init() error { + var err error + s.db, err = sql.Open("sqlite3", s.DBPath) + if err != nil { return err } + + createTables := ` + CREATE TABLE IF NOT EXISTS alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + type TEXT, + settings TEXT + ); + CREATE TABLE IF NOT EXISTS sites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT DEFAULT 'New Monitor', + url TEXT, + type TEXT DEFAULT 'http', + token TEXT, + interval INTEGER, + alert_id INTEGER, + check_ssl BOOLEAN DEFAULT 0, + threshold INTEGER DEFAULT 7, + max_retries INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + public_key TEXT NOT NULL, + role TEXT DEFAULT 'user' + );` + _, err = s.db.Exec(createTables) + return err +} + +func generateToken() string { + b := make([]byte, 16) + rand.Read(b) + 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 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) + sites = append(sites, st) + } + return sites +} +func (s *SQLiteStore) AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { + token := "" + if sType == "push" { token = generateToken() } + s.db.Exec("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", name, url, sType, token, interval, alertID, checkSSL, threshold, retries) +} +func (s *SQLiteStore) UpdateSite(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) { + var existingToken string + s.db.QueryRow("SELECT token FROM sites WHERE id=?", id).Scan(&existingToken) + if sType == "push" && existingToken == "" { existingToken = generateToken() } + s.db.Exec("UPDATE sites SET name=?, url=?, type=?, token=?, interval=?, alert_id=?, check_ssl=?, threshold=?, max_retries=? WHERE id=?", name, url, sType, existingToken, interval, alertID, checkSSL, threshold, retries, id) +} +func (s *SQLiteStore) DeleteSite(id int) { + s.db.Exec("DELETE FROM sites WHERE id=?", id) + var count int + s.db.QueryRow("SELECT COUNT(*) FROM sites").Scan(&count) + if count == 0 { s.db.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") } +} +func (s *SQLiteStore) GetAllAlerts() []models.AlertConfig { + rows, err := s.db.Query("SELECT id, name, type, settings FROM alerts") + if err != nil { return []models.AlertConfig{} } + defer rows.Close() + var alerts []models.AlertConfig + for rows.Next() { + var a models.AlertConfig; var settingsJSON string + rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + json.Unmarshal([]byte(settingsJSON), &a.Settings) + alerts = append(alerts, a) + } + return alerts +} +func (s *SQLiteStore) GetAlert(id int) (models.AlertConfig, bool) { + var a models.AlertConfig; var settingsJSON string + err := s.db.QueryRow("SELECT id, name, type, settings FROM alerts WHERE id = ?", id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) + if err != nil { return a, false } + json.Unmarshal([]byte(settingsJSON), &a.Settings) + return a, true +} +func (s *SQLiteStore) AddAlert(name, aType string, settings map[string]string) { + jsonBytes, _ := json.Marshal(settings) + s.db.Exec("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)", name, aType, string(jsonBytes)) +} +func (s *SQLiteStore) UpdateAlert(id int, name, aType string, settings map[string]string) { + jsonBytes, _ := json.Marshal(settings) + s.db.Exec("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?", name, aType, string(jsonBytes), id) +} +func (s *SQLiteStore) DeleteAlert(id int) { + s.db.Exec("DELETE FROM alerts WHERE id=?", id) + var count int + s.db.QueryRow("SELECT COUNT(*) FROM alerts").Scan(&count) + if count == 0 { s.db.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") } +} +func (s *SQLiteStore) GetAllUsers() []models.User { + rows, err := s.db.Query("SELECT id, username, public_key, role FROM users") + if err != nil { return []models.User{} } + defer rows.Close() + var users []models.User + for rows.Next() { + var u models.User + rows.Scan(&u.ID, &u.Username, &u.PublicKey, &u.Role) + users = append(users, u) + } + return users +} +func (s *SQLiteStore) AddUser(username, publicKey, role string) error { + _, err := s.db.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", username, publicKey, role) + return err +} +func (s *SQLiteStore) 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 { + tx, err := s.db.Begin() + if err != nil { return err } + + // Wipe Existing + tx.Exec("DELETE FROM sites"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='sites'") + tx.Exec("DELETE FROM alerts"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='alerts'") + tx.Exec("DELETE FROM users"); tx.Exec("DELETE FROM sqlite_sequence WHERE name='users'") + + // Insert New + for _, u := range data.Users { + tx.Exec("INSERT INTO users (username, public_key, role) VALUES (?, ?, ?)", u.Username, u.PublicKey, u.Role) + } + for _, a := range data.Alerts { + jsonBytes, _ := json.Marshal(a.Settings) + tx.Exec("INSERT INTO alerts (id, name, type, settings) VALUES (?, ?, ?, ?)", a.ID, a.Name, a.Type, string(jsonBytes)) + } + for _, st := range data.Sites { + tx.Exec("INSERT INTO sites (id, name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + st.ID, st.Name, st.URL, st.Type, st.Token, st.Interval, st.AlertID, st.CheckSSL, st.ExpiryThreshold, st.MaxRetries) + } + + return tx.Commit() +} \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..1af1aa1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,41 @@ +package store + +import ( + "go-upkeep/internal/models" +) + +type Store interface { + Init() error + + // Sites + GetSites() []models.Site + AddSite(name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) + UpdateSite(id int, name, url, sType string, interval, alertID int, checkSSL bool, threshold, retries int) + DeleteSite(id int) + + // Alerts + GetAllAlerts() []models.AlertConfig + GetAlert(id int) (models.AlertConfig, bool) + AddAlert(name, aType string, settings map[string]string) + UpdateAlert(id int, name, aType string, settings map[string]string) + DeleteAlert(id int) + + // Users + GetAllUsers() []models.User + AddUser(username, publicKey, role string) error + DeleteUser(id int) error + + // Phase 5: Backup & Restore + ExportData() models.Backup + ImportData(data models.Backup) error +} + +var Current Store + +func SetGlobal(s Store) { + Current = s +} + +func Get() Store { + return Current +} \ No newline at end of file diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go new file mode 100644 index 0000000..b83d054 --- /dev/null +++ b/internal/tui/tab_alerts.go @@ -0,0 +1,155 @@ +package tui + +import ( + "fmt" + "go-upkeep/internal/store" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + tea "github.com/charmbracelet/bubbletea" +) + +type alertFormData struct { + Name string + AlertType string + WebhookURL string + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPass string + EmailFrom string + EmailTo string +} + +func (m Model) viewAlertsTab() string { + var content string + content += fmt.Sprintf("\n%-3s %-15s %-10s %s\n", "ID", "NAME", "TYPE", "CONFIG") + content += subtleStyle.Render("----------------------------------------------------------------") + "\n" + end := m.tableOffset + m.maxTableRows + if end > len(m.alerts) { + end = len(m.alerts) + } + for i := m.tableOffset; i < end; i++ { + alert := m.alerts[i] + cursor := " " + if m.cursor == i { + cursor = ">" + } + confStr := "settings..." + if val, ok := alert.Settings["url"]; ok { + confStr = limitStr(val, 30) + } + if alert.Type == "email" { + confStr = fmt.Sprintf("SMTP: %s", alert.Settings["host"]) + } + row := fmt.Sprintf("%s %-3d %-15s %-10s %s", cursor, alert.ID, limitStr(alert.Name, 15), alert.Type, confStr) + if m.cursor == i { + row = lipgloss.NewStyle().Bold(true).Render(row) + } + content += row + "\n" + } + return content +} + +func (m *Model) initAlertHuhForm() tea.Cmd { + m.alertFormData = &alertFormData{ + AlertType: "discord", + } + + if m.editID > 0 { + for _, alert := range m.alerts { + if alert.ID == m.editID { + m.alertFormData.Name = alert.Name + m.alertFormData.AlertType = alert.Type + if url, ok := alert.Settings["url"]; ok { + m.alertFormData.WebhookURL = url + } + if alert.Type == "email" { + m.alertFormData.SMTPHost = alert.Settings["host"] + m.alertFormData.SMTPPort = alert.Settings["port"] + m.alertFormData.SMTPUser = alert.Settings["user"] + m.alertFormData.SMTPPass = alert.Settings["pass"] + m.alertFormData.EmailFrom = alert.Settings["from"] + m.alertFormData.EmailTo = alert.Settings["to"] + } + break + } + } + } + + m.huhForm = huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Alert Name"). + Placeholder("My Alert Channel"). + Value(&m.alertFormData.Name). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("name is required") + } + return nil + }), + huh.NewSelect[string]().Title("Alert Type"). + Options( + huh.NewOption("Discord", "discord"), + huh.NewOption("Slack", "slack"), + huh.NewOption("Webhook", "webhook"), + huh.NewOption("Email (SMTP)", "email"), + ).Value(&m.alertFormData.AlertType), + ).Title("Alert Config"), + huh.NewGroup( + huh.NewInput().Title("Webhook URL"). + Placeholder("https://discord.com/api/webhooks/..."). + Value(&m.alertFormData.WebhookURL), + ).Title("Webhook").WithHideFunc(func() bool { + return m.alertFormData.AlertType == "email" + }), + huh.NewGroup( + huh.NewInput().Title("SMTP Host"). + Placeholder("smtp.gmail.com"). + Value(&m.alertFormData.SMTPHost), + huh.NewInput().Title("SMTP Port"). + Placeholder("587"). + Value(&m.alertFormData.SMTPPort), + huh.NewInput().Title("SMTP User"). + Placeholder("user@gmail.com"). + Value(&m.alertFormData.SMTPUser), + huh.NewInput().Title("SMTP Password"). + EchoMode(huh.EchoModePassword). + Value(&m.alertFormData.SMTPPass), + huh.NewInput().Title("From Email"). + Placeholder("alerts@domain.com"). + Value(&m.alertFormData.EmailFrom), + huh.NewInput().Title("To Email"). + Placeholder("oncall@domain.com"). + Value(&m.alertFormData.EmailTo), + ).Title("Email Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "email" + }), + ).WithTheme(huh.ThemeDracula()) + + return m.huhForm.Init() +} + +func (m *Model) submitAlertForm() { + d := m.alertFormData + settings := make(map[string]string) + + if d.AlertType == "email" { + settings["host"] = d.SMTPHost + settings["port"] = d.SMTPPort + settings["user"] = d.SMTPUser + settings["pass"] = d.SMTPPass + settings["from"] = d.EmailFrom + settings["to"] = d.EmailTo + } else { + settings["url"] = d.WebhookURL + } + + if m.editID > 0 { + store.Get().UpdateAlert(m.editID, d.Name, d.AlertType, settings) + } else { + store.Get().AddAlert(d.Name, d.AlertType, settings) + } + m.state = stateDashboard +} diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go new file mode 100644 index 0000000..a55b51e --- /dev/null +++ b/internal/tui/tab_logs.go @@ -0,0 +1,5 @@ +package tui + +func (m Model) viewLogsTab() string { + return "\n" + m.logViewport.View() +} diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go new file mode 100644 index 0000000..659e9f2 --- /dev/null +++ b/internal/tui/tab_sites.go @@ -0,0 +1,363 @@ +package tui + +import ( + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "go-upkeep/internal/store" + "strconv" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +var sparkChars = []rune{'โ–', 'โ–‚', 'โ–ƒ', 'โ–„', 'โ–…', 'โ–†', 'โ–‡', 'โ–ˆ'} + +var ( + siteHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7D56F4")). + Bold(true). + Padding(0, 1) + + siteCellStyle = lipgloss.NewStyle().Padding(0, 1) + + siteSelectedStyle = lipgloss.NewStyle(). + Padding(0, 1). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#3b3b5c")) + + siteBorderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444")) + + siteColWidths = []int{4, 16, 8, 9, 8, 22, 10, 6} +) + +type siteFormData struct { + Name string + SiteType string + URL string + Interval string + AlertID string + CheckSSL bool + Threshold string + Retries string +} + +func latencySparkline(latencies []time.Duration, width int) string { + if len(latencies) == 0 { + return subtleStyle.Render(strings.Repeat("ยท", width)) + } + + samples := latencies + if len(samples) > width { + samples = samples[len(samples)-width:] + } + + minL, maxL := samples[0], samples[0] + for _, l := range samples { + if l < minL { + minL = l + } + if l > maxL { + maxL = l + } + } + + var sb strings.Builder + spread := maxL - minL + for _, l := range samples { + idx := 0 + if spread > 0 { + idx = int(float64(l-minL) / float64(spread) * 7) + if idx > 7 { + idx = 7 + } + } + ch := string(sparkChars[idx]) + ms := l.Milliseconds() + if ms < 200 { + sb.WriteString(specialStyle.Render(ch)) + } else if ms < 500 { + sb.WriteString(warnStyle.Render(ch)) + } else { + sb.WriteString(dangerStyle.Render(ch)) + } + } + + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) + } + return sb.String() +} + +func heartbeatSparkline(statuses []bool, width int) string { + if len(statuses) == 0 { + return subtleStyle.Render(strings.Repeat("ยท", width)) + } + + samples := statuses + if len(samples) > width { + samples = samples[len(samples)-width:] + } + + var sb strings.Builder + for _, up := range samples { + if up { + sb.WriteString(specialStyle.Render("โ–")) + } else { + sb.WriteString(dangerStyle.Render("โ–ˆ")) + } + } + + if remaining := width - len(samples); remaining > 0 { + sb.WriteString(subtleStyle.Render(strings.Repeat("ยท", remaining))) + } + return sb.String() +} + +func fmtLatency(d time.Duration) string { + ms := d.Milliseconds() + if ms == 0 { + return subtleStyle.Render("โ€”") + } + var s string + if ms < 1000 { + s = fmt.Sprintf("%dms", ms) + } else { + s = fmt.Sprintf("%.1fs", float64(ms)/1000) + } + if ms < 200 { + return specialStyle.Render(s) + } + if ms < 500 { + return warnStyle.Render(s) + } + return dangerStyle.Render(s) +} + +func fmtUptime(total, up int) string { + if total == 0 { + return subtleStyle.Render("โ€”") + } + pct := float64(up) / float64(total) * 100 + s := fmt.Sprintf("%.1f%%", pct) + if pct >= 99 { + return specialStyle.Render(s) + } + if pct >= 95 { + return warnStyle.Render(s) + } + return dangerStyle.Render(s) +} + +func fmtSSL(site models.Site) string { + if site.Type != "http" || !site.CheckSSL || !site.HasSSL { + return subtleStyle.Render("-") + } + days := int(time.Until(site.CertExpiry).Hours() / 24) + s := fmt.Sprintf("%dd", days) + if days <= 0 { + return dangerStyle.Render("EXPIRED") + } + if days <= site.ExpiryThreshold { + return warnStyle.Render(s) + } + return specialStyle.Render(s) +} + +func fmtRetries(site models.Site) string { + retriesDone := site.FailureCount - 1 + if retriesDone < 0 { + retriesDone = 0 + } + dispCount := retriesDone + if dispCount > site.MaxRetries { + dispCount = site.MaxRetries + } + s := fmt.Sprintf("%d/%d", dispCount, site.MaxRetries) + if site.Status == "DOWN" { + return dangerStyle.Render(s) + } + if site.Status == "UP" && site.FailureCount > 0 { + return warnStyle.Render(s) + } + return s +} + +func fmtStatus(status string) string { + switch { + case status == "DOWN" || status == "SSL EXP": + return dangerStyle.Render(status) + case status == "PENDING": + return subtleStyle.Render(status) + default: + return specialStyle.Render(status) + } +} + +func (m Model) viewSitesTab() string { + const sparkWidth = 20 + + if len(m.sites) == 0 { + return "\n No sites configured. Press [n] to add one." + } + + end := m.tableOffset + m.maxTableRows + if end > len(m.sites) { + end = len(m.sites) + } + + selectedVisual := m.cursor - m.tableOffset + + var rows [][]string + for i := m.tableOffset; i < end; i++ { + site := m.sites[i] + hist, _ := monitor.GetHistory(site.ID) + + var spark string + if site.Type == "push" { + spark = heartbeatSparkline(hist.Statuses, sparkWidth) + } else { + spark = latencySparkline(hist.Latencies, sparkWidth) + } + + rows = append(rows, []string{ + strconv.Itoa(site.ID), + m.zones.Mark(fmt.Sprintf("site-%d", i), limitStr(site.Name, 15)), + fmtStatus(site.Status), + fmtLatency(site.Latency), + fmtUptime(hist.TotalChecks, hist.UpChecks), + spark, + fmtSSL(site), + fmtRetries(site), + }) + } + + t := table.New(). + Border(lipgloss.RoundedBorder()). + BorderStyle(siteBorderStyle). + Headers("ID", "NAME", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"). + Rows(rows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + s := siteHeaderStyle + if col < len(siteColWidths) { + s = s.Width(siteColWidths[col]) + } + return s + } + 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 { + m.siteFormData = &siteFormData{ + SiteType: "http", + Interval: "60", + Threshold: "7", + Retries: "0", + } + + if m.editID > 0 { + for _, site := range m.sites { + if site.ID == m.editID { + m.siteFormData.Name = site.Name + m.siteFormData.SiteType = site.Type + m.siteFormData.URL = site.URL + m.siteFormData.Interval = strconv.Itoa(site.Interval) + m.siteFormData.AlertID = strconv.Itoa(site.AlertID) + m.siteFormData.CheckSSL = site.CheckSSL + m.siteFormData.Threshold = strconv.Itoa(site.ExpiryThreshold) + m.siteFormData.Retries = strconv.Itoa(site.MaxRetries) + break + } + } + } + + alertOpts := []huh.Option[string]{huh.NewOption("None", "0")} + if store.Get() != nil { + for _, a := range store.Get().GetAllAlerts() { + alertOpts = append(alertOpts, huh.NewOption( + fmt.Sprintf("%s (%s)", a.Name, a.Type), + strconv.Itoa(a.ID), + )) + } + } + + m.huhForm = huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Monitor Name"). + Placeholder("My Service"). + Value(&m.siteFormData.Name). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("name is required") + } + return nil + }), + huh.NewSelect[string]().Title("Monitor Type"). + Options( + huh.NewOption("HTTP/HTTPS", "http"), + huh.NewOption("Push / Heartbeat", "push"), + ).Value(&m.siteFormData.SiteType), + huh.NewInput().Title("URL"). + Placeholder("https://example.com"). + Description("Required for HTTP monitors"). + Value(&m.siteFormData.URL), + huh.NewInput().Title("Check Interval (seconds)"). + Placeholder("60"). + Value(&m.siteFormData.Interval), + huh.NewSelect[string]().Title("Alert Channel"). + Options(alertOpts...). + Value(&m.siteFormData.AlertID), + ).Title("Monitor Settings"), + huh.NewGroup( + huh.NewConfirm().Title("Monitor SSL Certificate?"). + Value(&m.siteFormData.CheckSSL), + huh.NewInput().Title("SSL Warning Threshold (days)"). + Placeholder("7"). + Value(&m.siteFormData.Threshold), + huh.NewInput().Title("Max Retries Before Alert"). + Placeholder("0"). + Value(&m.siteFormData.Retries), + ).Title("Advanced"), + ).WithTheme(huh.ThemeDracula()) + + return m.huhForm.Init() +} + +func (m *Model) submitSiteForm() { + d := m.siteFormData + interval, _ := strconv.Atoi(d.Interval) + alertID, _ := strconv.Atoi(d.AlertID) + threshold, _ := strconv.Atoi(d.Threshold) + retries, _ := strconv.Atoi(d.Retries) + if interval < 1 { + interval = 60 + } + if threshold < 1 { + threshold = 7 + } + + if m.editID > 0 { + store.Get().UpdateSite(m.editID, d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries) + monitor.UpdateSiteConfig(m.editID, d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries) + } else { + store.Get().AddSite(d.Name, d.URL, d.SiteType, interval, alertID, d.CheckSSL, threshold, retries) + } + m.state = stateDashboard +} diff --git a/internal/tui/tab_users.go b/internal/tui/tab_users.go new file mode 100644 index 0000000..a87c008 --- /dev/null +++ b/internal/tui/tab_users.go @@ -0,0 +1,72 @@ +package tui + +import ( + "fmt" + "go-upkeep/internal/store" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +type userFormData struct { + Username string + PublicKey string +} + +func (m Model) viewUsersTab() string { + var content string + content += fmt.Sprintf("\n%-3s %-15s %-10s %s\n", "ID", "USER", "ROLE", "KEY") + content += subtleStyle.Render("----------------------------------------------------------------") + "\n" + end := m.tableOffset + m.maxTableRows + if end > len(m.users) { + end = len(m.users) + } + for i := m.tableOffset; i < end; i++ { + u := m.users[i] + cursor := " " + if m.cursor == i { + cursor = ">" + } + row := fmt.Sprintf("%s %-3d %-15s %-10s %s", cursor, u.ID, limitStr(u.Username, 15), u.Role, limitStr(u.PublicKey, 40)) + if m.cursor == i { + row = lipgloss.NewStyle().Bold(true).Render(row) + } + content += row + "\n" + } + return content +} + +func (m *Model) initUserHuhForm() tea.Cmd { + m.userFormData = &userFormData{} + + m.huhForm = huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Username"). + Placeholder("admin"). + Value(&m.userFormData.Username). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("username is required") + } + return nil + }), + huh.NewInput().Title("SSH Public Key"). + Placeholder("ssh-ed25519 AAAA..."). + Value(&m.userFormData.PublicKey). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("public key is required") + } + return nil + }), + ).Title("SSH Access"), + ).WithTheme(huh.ThemeDracula()) + + return m.huhForm.Init() +} + +func (m *Model) submitUserForm() { + store.Get().AddUser(m.userFormData.Username, m.userFormData.PublicKey, "user") + m.state = stateUsers +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..4dcf90b --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,426 @@ +package tui + +import ( + "fmt" + "go-upkeep/internal/models" + "go-upkeep/internal/monitor" + "go-upkeep/internal/store" + "math" + "sort" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/harmonica" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + zone "github.com/lrstanley/bubblezone" +) + +var ( + subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}) + specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true) + + activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(lipgloss.Color("#7D56F4")).Foreground(lipgloss.Color("#7D56F4")).Bold(true).Padding(0, 1) + inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"}) +) + +var pulseFrames = []string{"โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "} + +type sessionState int + +const ( + stateDashboard sessionState = iota + stateLogs + stateUsers + stateFormSite + stateFormAlert + stateFormUser +) + +type Model struct { + state sessionState + currentTab int + cursor int + tableOffset int + maxTableRows int + editID int + editToken string + + huhForm *huh.Form + siteFormData *siteFormData + alertFormData *alertFormData + userFormData *userFormData + + logViewport viewport.Model + isAdmin bool + zones *zone.Manager + + // harmonica animation state + pulseSpring harmonica.Spring + pulsePos float64 + pulseVel float64 + tickCount int + + sites []models.Site + alerts []models.AlertConfig + users []models.User +} + +func InitialModel(isAdmin bool) Model { + vpLogs := viewport.New(100, 20) + vpLogs.SetContent("Waiting for logs...") + z := zone.New() + spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) + return Model{ + state: stateDashboard, + logViewport: vpLogs, + maxTableRows: 5, + isAdmin: isAdmin, + zones: z, + pulseSpring: spring, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(tea.ClearScreen, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t })) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + // Form state: forward ALL messages to huh (keys, timers, resize, etc.) + if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + if keyMsg.String() == "ctrl+c" { + return m, tea.Quit + } + if keyMsg.String() == "esc" { + m.huhForm = nil + m.state = stateDashboard + if m.currentTab == 3 { + m.state = stateUsers + } + return m, nil + } + } + if m.huhForm != nil { + form, formCmd := m.huhForm.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.huhForm = f + } + if m.huhForm.State == huh.StateCompleted { + m.submitForm() + m.refreshData() + m.huhForm = nil + return m, nil + } + return m, formCmd + } + return m, nil + } + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.maxTableRows = msg.Height - 12 + if m.maxTableRows < 1 { + m.maxTableRows = 1 + } + m.logViewport.Width = msg.Width + m.logViewport.Height = msg.Height - 6 + return m, tea.ClearScreen + + case time.Time: + m.refreshData() + m.tickCount++ + target := math.Sin(float64(m.tickCount)*0.3)*0.5 + 0.5 + m.pulsePos, m.pulseVel = m.pulseSpring.Update(m.pulsePos, m.pulseVel, target) + return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return t }) + + case tea.MouseMsg: + if m.state == stateDashboard || m.state == stateLogs || m.state == stateUsers { + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + return m.handleClick(msg) + } + } + + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + if msg.String() == "ctrl+l" { + return m, tea.ClearScreen + } + + switch m.state { + case stateDashboard, stateLogs, stateUsers: + switch msg.String() { + case "q": + return m, tea.Quit + case "tab": + m.switchTab(m.currentTab + 1) + case "pgup", "pgdown": + if m.state == stateLogs { + m.logViewport, cmd = m.logViewport.Update(msg) + return m, cmd + } + case "up", "k": + if m.state == stateLogs { + m.logViewport.LineUp(1) + } else if m.cursor > 0 { + m.cursor-- + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + } + } + case "down", "j": + if m.state == stateLogs { + m.logViewport.LineDown(1) + } else { + max := len(m.sites) - 1 + if m.currentTab == 1 { + max = len(m.alerts) - 1 + } + if m.currentTab == 3 { + max = len(m.users) - 1 + } + if m.cursor < max { + m.cursor++ + if m.cursor >= m.tableOffset+m.maxTableRows { + m.tableOffset++ + } + } + } + case "n": + m.editID = 0 + m.editToken = "" + if m.currentTab == 0 { + m.state = stateFormSite + return m, m.initSiteHuhForm() + } else if m.currentTab == 1 { + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } else if m.currentTab == 3 && m.isAdmin { + m.state = stateFormUser + return m, m.initUserHuhForm() + } + case "e", "enter": + if m.currentTab == 0 && len(m.sites) > 0 { + m.editID = m.sites[m.cursor].ID + m.editToken = m.sites[m.cursor].Token + m.state = stateFormSite + return m, m.initSiteHuhForm() + } else if m.currentTab == 1 && len(m.alerts) > 0 { + m.editID = m.alerts[m.cursor].ID + m.state = stateFormAlert + return m, m.initAlertHuhForm() + } + case "d", "backspace": + if m.currentTab == 1 && len(m.alerts) > 0 { + store.Get().DeleteAlert(m.alerts[m.cursor].ID) + m.adjustCursor(len(m.alerts) - 1) + } else if m.currentTab == 0 && len(m.sites) > 0 { + id := m.sites[m.cursor].ID + store.Get().DeleteSite(id) + monitor.RemoveSite(id) + m.adjustCursor(len(m.sites) - 1) + } else if m.currentTab == 3 && m.isAdmin && len(m.users) > 0 { + store.Get().DeleteUser(m.users[m.cursor].ID) + m.adjustCursor(len(m.users) - 1) + } + m.refreshData() + } + } + } + return m, nil +} + +func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + maxTabs := 3 + if !m.isAdmin { + maxTabs = 2 + } + for i := 0; i <= maxTabs; i++ { + if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) { + m.switchTab(i) + return m, nil + } + } + + if m.currentTab == 0 { + end := m.tableOffset + m.maxTableRows + if end > len(m.sites) { + end = len(m.sites) + } + for i := m.tableOffset; i < end; i++ { + if m.zones.Get(fmt.Sprintf("site-%d", i)).InBounds(msg) { + m.cursor = i + return m, nil + } + } + } + + return m, nil +} + +func (m *Model) switchTab(idx int) { + maxTabs := 2 + if m.isAdmin { + maxTabs = 3 + } + if idx > maxTabs { + idx = 0 + } + m.currentTab = idx + m.cursor = 0 + m.tableOffset = 0 + switch idx { + case 2: + m.state = stateLogs + case 3: + m.state = stateUsers + default: + m.state = stateDashboard + } +} + +func (m *Model) adjustCursor(newLen int) { + if m.cursor >= newLen && m.cursor > 0 { + m.cursor-- + } + if m.cursor < m.tableOffset { + m.tableOffset = m.cursor + if m.tableOffset < 0 { + m.tableOffset = 0 + } + } +} + +func (m *Model) refreshData() { + monitor.Mutex.RLock() + var sites []models.Site + for _, s := range monitor.LiveState { + sites = append(sites, s) + } + monitor.Mutex.RUnlock() + sort.Slice(sites, func(i, j int) bool { return sites[i].ID < sites[j].ID }) + m.sites = sites + if store.Get() != nil { + m.alerts = store.Get().GetAllAlerts() + if m.isAdmin { + m.users = store.Get().GetAllUsers() + } + } + m.logViewport.SetContent(strings.Join(monitor.GetLogs(), "\n")) +} + +func (m *Model) submitForm() { + if store.Get() == nil { + return + } + switch m.state { + case stateFormSite: + if m.siteFormData != nil { + m.submitSiteForm() + } + case stateFormAlert: + if m.alertFormData != nil { + m.submitAlertForm() + } + case stateFormUser: + if m.userFormData != nil { + m.submitUserForm() + } + } +} + +func (m Model) pulseIndicator() string { + frame := m.tickCount % len(pulseFrames) + brightness := int(m.pulsePos*155) + 100 + if brightness > 255 { + brightness = 255 + } + color := fmt.Sprintf("#%02x%02x%02x", brightness/3, brightness, brightness/2) + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(pulseFrames[frame]) +} + +func (m Model) View() string { + switch m.state { + case stateFormSite, stateFormAlert, stateFormUser: + if m.huhForm != nil { + title := "" + switch m.state { + case stateFormSite: + title = "Add Monitor" + if m.editID > 0 { + title = fmt.Sprintf("Edit Monitor #%d", m.editID) + } + case stateFormAlert: + title = "Add Alert" + if m.editID > 0 { + title = fmt.Sprintf("Edit Alert #%d", m.editID) + } + case stateFormUser: + title = "Add User" + } + header := titleStyle.Render(title) + footer := subtleStyle.Render("\n[Esc] Cancel") + return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n\n" + m.huhForm.View() + "\n" + footer) + } + return "" + default: + return m.zones.Scan(m.viewDashboard()) + } +} + +func (m Model) viewDashboard() string { + tabs := []string{"Sites", "Alerts", "Logs"} + if m.isAdmin { + tabs = append(tabs, "Users") + } + var renderedTabs []string + for i, t := range tabs { + var rendered string + if i == m.currentTab { + rendered = activeTab.Render(t) + } else { + rendered = inactiveTab.Render(t) + } + renderedTabs = append(renderedTabs, m.zones.Mark(fmt.Sprintf("tab-%d", i), rendered)) + } + header := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) + + pulse := m.pulseIndicator() + header = pulse + " " + header + + var content string + switch m.currentTab { + case 0: + content = m.viewSitesTab() + case 1: + content = m.viewAlertsTab() + case 2: + content = m.viewLogsTab() + case 3: + if m.isAdmin { + content = m.viewUsersTab() + } + } + + footer := subtleStyle.Render("\n[n] New [e/Enter] Edit [d] Delete [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + if m.currentTab == 3 { + footer = subtleStyle.Render("\n[n] Add User [d] Revoke [Tab/Click] Switch [Ctrl+L] Clear [q] Quit") + } + return lipgloss.NewStyle().Padding(1, 2).Render(header + "\n" + content + "\n" + footer) +} + +func limitStr(text string, max int) string { + if len(text) > max { + return text[:max-3] + "..." + } + return text +}