feat: initial commit — uptime monitor (forked from go-upkeep)
Go-based uptime monitor with SQLite/Postgres storage, TUI dashboard, SSH server, alerting, and clustering support.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
tmp/
|
||||
vendor/
|
||||
@@ -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 }}
|
||||
+39
@@ -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/
|
||||
+28
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }()
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<!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 { Title string; Sites []models.Site }{Title: title, Sites: sites}
|
||||
t.Execute(w, data)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package tui
|
||||
|
||||
func (m Model) viewLogsTab() string {
|
||||
return "\n" + m.logViewport.View()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user