12 Commits

Author SHA1 Message Date
lerko b70edaace5 Merge pull request 'chore: rename project from go-upkeep to uptop' (#25) from chore/rename-uptop into main
CI / test (push) Successful in 4m26s
CI / lint (push) Successful in 1m1s
Reviewed-on: lerko/uptime#25
2026-05-25 01:02:30 +00:00
lerko 9d12e3ecf1 chore: complete rename from go-upkeep to uptop
CI / test (pull_request) Successful in 4m26s
CI / lint (pull_request) Successful in 1m11s
- Module path: gitea.lerkolabs.com/lerko/uptop
- Binary: cmd/uptop/
- All imports updated to full module path
- Env vars: UPKEEP_* → UPTOP_*
- Prometheus metrics: upkeep_* → uptop_*
- Default DB: uptop.db
- Docker image: lerko/uptop
- All docs, compose files, CI updated

Only remaining "go-upkeep" reference is the fork attribution in README.
2026-05-24 20:20:35 -04:00
lerko 36a4b69837 Merge pull request 'feat(tui): theme system with 5 curated dark palettes' (#24) from feat/themes into main
CI / test (push) Successful in 4m51s
CI / lint (push) Successful in 1m12s
Reviewed-on: lerko/uptime#24
2026-05-24 23:30:25 +00:00
lerko fee84c9363 fix(tui): tighten zebra row contrast for Tokyo Night and Gruvbox
CI / test (pull_request) Successful in 4m48s
CI / lint (pull_request) Successful in 1m11s
Previous ZebraBg was too far from Bg, washing out text on those
themes. Reduced to a 2-step shift for subtle row alternation.
2026-05-24 19:19:51 -04:00
lerko 87edd4aa40 feat(tui): swap light theme for Tokyo Night and Gruvbox
Light theme doesn't work well on dark terminals. Replace with
two proven dark palettes. Now 5 themes: Flexoki Dark, Tokyo Night,
Catppuccin Mocha, Nord, Gruvbox.
2026-05-24 19:10:29 -04:00
lerko 602f1b2c52 feat(tui): add theme system with 4 curated palettes
Flexoki Dark (default), Flexoki Light, Catppuccin Mocha, Nord.
Press T to cycle themes; selection persists in preferences.

All hardcoded colors replaced with theme-driven values.
Dedicated ZebraBg per theme for subtle row striping.
2026-05-24 19:05:40 -04:00
lerko 6e659cf6ee Merge pull request 'fix(tui): scope form validators to relevant monitor types' (#23) from fix/ssl-threshold-validation into main
CI / test (push) Successful in 4m48s
CI / lint (push) Successful in 56s
Reviewed-on: lerko/uptime#23
2026-05-24 22:03:33 +00:00
lerko 0a56f01929 fix(tui): guard max retries validator for group type
CI / test (pull_request) Successful in 4m40s
CI / lint (pull_request) Successful in 1m1s
Consistent with interval/timeout validators that already skip for
group monitors. Prevents potential validation block if field is
cleared while editing.
2026-05-24 17:45:19 -04:00
lerko b5b9cc81a5 fix(tui): skip irrelevant field validation by monitor type
URL, SSL threshold, and port validators blocked form progression
when editing monitors that don't use those fields (e.g. ping monitors
failing URL validation, non-SSL sites failing threshold check).

Scope each validator to fire only for its relevant monitor type.
2026-05-24 17:38:40 -04:00
lerko f64b46f055 Merge pull request 'ci: cache Go build artifacts between runs' (#22) from chore/ci-cache into main
CI / test (push) Successful in 4m40s
CI / lint (push) Successful in 1m12s
Reviewed-on: lerko/uptime#22
2026-05-24 20:01:05 +00:00
lerko d038361320 ci: cache Go build artifacts between runs
CI / test (pull_request) Successful in 6m18s
CI / lint (pull_request) Successful in 56s
2026-05-24 15:52:21 -04:00
lerko d03dc0c1ea Merge pull request 'docs: community polish for public readiness' (#21) from chore/community-polish into main
CI / test (push) Successful in 4m44s
CI / lint (push) Successful in 1m7s
Reviewed-on: lerko/uptime#21
2026-05-24 19:40:00 +00:00
41 changed files with 486 additions and 259 deletions
+6
View File
@@ -18,6 +18,12 @@ jobs:
with: with:
go-version: "1.24" go-version: "1.24"
- uses: actions/cache@v4
with:
path: ~/.cache/go-build
key: go-build-${{ hashFiles('**/*.go', 'go.sum') }}
restore-keys: go-build-
- name: Install build tools - name: Install build tools
run: apk add --no-cache gcc musl-dev run: apk add --no-cache gcc musl-dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/go-upkeep images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop
tags: | tags: |
# This turns git tag "v1.0.0" into docker tag "1.0.0" # This turns git tag "v1.0.0" into docker tag "1.0.0"
type=semver,pattern={{version}} type=semver,pattern={{version}}
+2 -5
View File
@@ -26,8 +26,8 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go # End of https://www.toptal.com/developers/gitignore/api/go
/goupkeep /uptop
upkeep.db uptop.db
.ssh .ssh
@@ -35,8 +35,5 @@ authorized_keys
tmp tmp
# Old repo
/go-upkeep/
*.local.json *.local.json
*.local.md *.local.md
+1 -1
View File
@@ -3,7 +3,7 @@
## Development ## Development
```sh ```sh
go run cmd/goupkeep/main.go -demo # starts with sample data go run cmd/uptop/main.go -demo # starts with sample data
ssh -p 23234 localhost # connect to TUI ssh -p 23234 localhost # connect to TUI
``` ```
+7 -7
View File
@@ -9,7 +9,7 @@ ENV CGO_ENABLED=1
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=none ARG COMMIT=none
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o go-upkeep ./cmd/goupkeep/main.go RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go
# --- Stage 2: Runner --- # --- Stage 2: Runner ---
FROM alpine:latest FROM alpine:latest
@@ -17,15 +17,15 @@ WORKDIR /app
RUN apk add --no-cache ca-certificates openssh-client RUN apk add --no-cache ca-certificates openssh-client
RUN mkdir /data RUN mkdir /data
COPY --from=builder /app/go-upkeep . COPY --from=builder /app/uptop .
# Set Default Configuration via ENV # Set Default Configuration via ENV
# Docker users can override these in docker-compose.yml # Docker users can override these in docker-compose.yml
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
ENV UPKEEP_DB_TYPE=sqlite ENV UPTOP_DB_TYPE=sqlite
ENV UPKEEP_DB_DSN=/data/upkeep.db ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPKEEP_KEYS=/data/authorized_keys ENV UPTOP_KEYS=/data/authorized_keys
ENV UPKEEP_PORT=23234 ENV UPTOP_PORT=23234
EXPOSE 23234 EXPOSE 23234
CMD ["./go-upkeep"] CMD ["./uptop"]
+26 -26
View File
@@ -1,4 +1,4 @@
# Go-Upkeep # uptop
Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`. Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`.
@@ -18,14 +18,14 @@ Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upk
## Quick start ## Quick start
```bash ```bash
go run cmd/goupkeep/main.go go run cmd/uptop/main.go
ssh -p 23234 localhost ssh -p 23234 localhost
``` ```
Seed some demo data to see it in action: Seed some demo data to see it in action:
```bash ```bash
go run cmd/goupkeep/main.go -demo go run cmd/uptop/main.go -demo
``` ```
## Install ## Install
@@ -33,34 +33,34 @@ go run cmd/goupkeep/main.go -demo
### From source ### From source
```bash ```bash
go install gitea.lerkolabs.com/lerko/uptime/cmd/goupkeep@latest go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
``` ```
### Docker ### Docker
```bash ```bash
docker pull lerko/go-upkeep:latest docker pull lerko/uptop:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/go-upkeep docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
``` ```
### Binary ### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases). Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
## Config as code ## Config as code
Export your current monitors: Export your current monitors:
```bash ```bash
goupkeep export -o monitors.yaml uptop export -o monitors.yaml
``` ```
Apply a config file: Apply a config file:
```bash ```bash
goupkeep apply -f monitors.yaml uptop apply -f monitors.yaml
goupkeep apply -f monitors.yaml --dry-run # see what would change uptop apply -f monitors.yaml --dry-run # see what would change
goupkeep apply -f monitors.yaml --prune # delete anything not in the YAML uptop apply -f monitors.yaml --prune # delete anything not in the YAML
``` ```
See [docs/config-as-code.md](docs/config-as-code.md) for the full reference. See [docs/config-as-code.md](docs/config-as-code.md) for the full reference.
@@ -81,28 +81,28 @@ services:
- ./data:/data - ./data:/data
- ./ssh_keys:/app/.ssh - ./ssh_keys:/app/.ssh
environment: environment:
- UPKEEP_DB_TYPE=sqlite - UPTOP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db - UPTOP_DB_DSN=/data/uptop.db
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_CLUSTER_SECRET=change-me - UPTOP_CLUSTER_SECRET=change-me
``` ```
First run: attach to the container (`docker attach go-upkeep`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH. First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
## Environment variables ## Environment variables
| Variable | Default | What it does | | Variable | Default | What it does |
|---|---|---| |---|---|---|
| `UPKEEP_PORT` | `23234` | SSH server port | | `UPTOP_PORT` | `23234` | SSH server port |
| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) | | `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` | | `UPTOP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string | | `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page | | `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title | | `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` | | `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes | | `UPTOP_PEER_URL` | | Leader URL for follower nodes |
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth | | `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | | `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
## Migrating from Uptime Kuma ## Migrating from Uptime Kuma
+32 -32
View File
@@ -5,14 +5,14 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"go-upkeep/internal/cluster" "gitea.lerkolabs.com/lerko/uptop/internal/cluster"
"go-upkeep/internal/config" "gitea.lerkolabs.com/lerko/uptop/internal/config"
"go-upkeep/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/importer"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/server" "gitea.lerkolabs.com/lerko/uptop/internal/server"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"go-upkeep/internal/tui" "gitea.lerkolabs.com/lerko/uptop/internal/tui"
"log" "log"
"os" "os"
"os/signal" "os/signal"
@@ -54,9 +54,9 @@ func main() {
func printVersion() { func printVersion() {
if version == "dev" { if version == "dev" {
fmt.Println("go-upkeep dev") fmt.Println("uptop dev")
} else { } else {
fmt.Printf("go-upkeep %s (%s, %s)\n", version, commit, date) fmt.Printf("uptop %s (%s, %s)\n", version, commit, date)
} }
} }
@@ -91,8 +91,8 @@ func runApply(args []string) {
filePath := fs.String("f", "", "Path to YAML config file (required)") filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying") dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
if *filePath == "" { if *filePath == "" {
@@ -124,8 +124,8 @@ func runApply(args []string) {
func runExport(args []string) { func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError) fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)") outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn) s := openStore(*dbType, *dsn)
@@ -145,7 +145,7 @@ func runExport(args []string) {
func runServe(args []string) { func runServe(args []string) {
portVal := 23234 portVal := 23234
dbType := "sqlite" dbType := "sqlite"
dbDSN := "upkeep.db" dbDSN := "uptop.db"
httpPort := 8080 httpPort := 8080
enableStatus := false enableStatus := false
statusTitle := "System Status" statusTitle := "System Status"
@@ -153,50 +153,50 @@ func runServe(args []string) {
clusterPeer := "" clusterPeer := ""
clusterKey := "" clusterKey := ""
if v := os.Getenv("UPKEEP_PORT"); v != "" { if v := os.Getenv("UPTOP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil { if p, err := strconv.Atoi(v); err == nil {
portVal = p portVal = p
} }
} }
if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" { if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
dbType = v dbType = v
} }
if v := os.Getenv("UPKEEP_DB_DSN"); v != "" { if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
dbDSN = v dbDSN = v
} }
if v := os.Getenv("UPKEEP_HTTP_PORT"); v != "" { if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil { if p, err := strconv.Atoi(v); err == nil {
httpPort = p httpPort = p
} }
} }
if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" { if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
enableStatus = true enableStatus = true
} }
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
statusTitle = v statusTitle = v
} }
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
clusterMode = v clusterMode = v
} }
if v := os.Getenv("UPKEEP_PEER_URL"); v != "" { if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
clusterPeer = v clusterPeer = v
} }
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
clusterKey = v clusterKey = v
} }
nodeID := os.Getenv("UPKEEP_NODE_ID") nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPKEEP_NODE_NAME") nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPKEEP_NODE_REGION") nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY") aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
if clusterMode == "probe" { if clusterMode == "probe" {
if nodeID == "" { if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
os.Exit(1) os.Exit(1)
} }
if clusterPeer == "" { if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
os.Exit(1) os.Exit(1)
} }
@@ -270,7 +270,7 @@ func runServe(args []string) {
} }
eng := monitor.NewEngine(s) eng := monitor.NewEngine(s)
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true) eng.SetInsecureSkipVerify(true)
} }
if aggStrategy != "" { if aggStrategy != "" {
@@ -305,7 +305,7 @@ func runServe(args []string) {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
} }
} else { } else {
fmt.Println("Go-Upkeep running in HEADLESS mode") fmt.Println("uptop running in HEADLESS mode")
done := make(chan os.Signal, 1) done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-done <-done
+21 -21
View File
@@ -4,21 +4,21 @@ services:
# ------------------------- # -------------------------
leader: leader:
build: . build: .
container_name: upkeep-leader container_name: uptop-leader
ports: ports:
- "23234:23234" # SSH - "23234:23234" # SSH
- "8080:8080" # HTTP - "8080:8080" # HTTP
environment: environment:
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
# Note: Port 5432 is correct here because we are talking INSIDE the network # 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 - UPTOP_DB_DSN=postgres://devuser:devpass@leader-db:5432/uptop_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Leader Node - UPTOP_STATUS_TITLE=Leader Node
# Cluster Config # Cluster Config
- UPKEEP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=mysecret - UPTOP_CLUSTER_SECRET=mysecret
depends_on: depends_on:
- leader-db - leader-db
stdin_open: true stdin_open: true
@@ -26,11 +26,11 @@ services:
leader-db: leader-db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-leader-db container_name: uptop-leader-db
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
volumes: volumes:
- ./tmp/leader-data:/var/lib/postgresql/data - ./tmp/leader-data:/var/lib/postgresql/data
@@ -39,23 +39,23 @@ services:
# ------------------------- # -------------------------
follower: follower:
build: . build: .
container_name: upkeep-follower container_name: uptop-follower
ports: ports:
- "23233:23234" # SSH (Mapped to different host port) - "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (Mapped to different host port) - "8081:8080" # HTTP (Mapped to different host port)
environment: environment:
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
# Connects to its OWN database # Connects to its OWN database
- UPKEEP_DB_DSN=postgres://devuser:devpass@follower-db:5432/upkeep_dev?sslmode=disable - UPTOP_DB_DSN=postgres://devuser:devpass@follower-db:5432/uptop_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Follower Node - UPTOP_STATUS_TITLE=Follower Node
# Cluster Config # Cluster Config
- UPKEEP_CLUSTER_MODE=follower - UPTOP_CLUSTER_MODE=follower
- UPKEEP_CLUSTER_SECRET=mysecret - UPTOP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally # IMPORTANT: Uses the Service Name "leader" to connect internally
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
depends_on: depends_on:
- follower-db - follower-db
stdin_open: true stdin_open: true
@@ -63,10 +63,10 @@ services:
follower-db: follower-db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-follower-db container_name: uptop-follower-db
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
volumes: volumes:
- ./tmp/follower-data:/var/lib/postgresql/data - ./tmp/follower-data:/var/lib/postgresql/data
+8 -8
View File
@@ -4,19 +4,19 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: upkeep-dev container_name: uptop-dev
ports: ports:
- "23234:23234" # SSH Access - "23234:23234" # SSH Access
- "8080:8080" # HTTP (Push Monitors + Status Page) - "8080:8080" # HTTP (Push Monitors + Status Page)
environment: environment:
# --- Database Configuration (Postgres) --- # --- Database Configuration (Postgres) ---
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
- UPKEEP_DB_DSN=postgres://devuser:devpass@postgres:5432/upkeep_dev?sslmode=disable - UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
# --- Web Server Configuration (Phase 4) --- # --- Web Server Configuration (Phase 4) ---
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Dev Infrastructure Status - UPTOP_STATUS_TITLE=Dev Infrastructure Status
depends_on: depends_on:
- postgres - postgres
stdin_open: true # Required for 'docker attach' (Local Admin Console) stdin_open: true # Required for 'docker attach' (Local Admin Console)
@@ -25,11 +25,11 @@ services:
# The Database # The Database
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-postgres container_name: uptop-postgres
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
ports: ports:
- "5432:5432" # Expose for external DB tools (DBeaver, etc.) - "5432:5432" # Expose for external DB tools (DBeaver, etc.)
volumes: volumes:
+16 -16
View File
@@ -2,10 +2,10 @@ services:
leader: leader:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
- UPKEEP_AGG_STRATEGY=any-down - UPTOP_AGG_STRATEGY=any-down
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
ports: ports:
- "8080:8080" - "8080:8080"
- "23234:23234" - "23234:23234"
@@ -13,23 +13,23 @@ services:
probe-us-east: probe-us-east:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=us-east-1 - UPTOP_NODE_ID=us-east-1
- UPKEEP_NODE_NAME=US East Probe - UPTOP_NODE_NAME=US East Probe
- UPKEEP_NODE_REGION=us-east - UPTOP_NODE_REGION=us-east
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
probe-eu-west: probe-eu-west:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=eu-west-1 - UPTOP_NODE_ID=eu-west-1
- UPKEEP_NODE_NAME=EU West Probe - UPTOP_NODE_NAME=EU West Probe
- UPKEEP_NODE_REGION=eu-west - UPTOP_NODE_REGION=eu-west
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
+6 -6
View File
@@ -3,16 +3,16 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: upkeep container_name: uptop
restart: unless-stopped restart: unless-stopped
ports: ports:
- "23234:23234" - "23234:23234"
- "8080:8080" - "8080:8080"
environment: environment:
- UPKEEP_DB_TYPE=sqlite - UPTOP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db - UPTOP_DB_DSN=/data/uptop.db
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=System Status - UPTOP_STATUS_TITLE=System Status
volumes: volumes:
- ./data:/data - ./data:/data
+13 -13
View File
@@ -7,13 +7,13 @@ Define your monitors and alerts in a YAML file. Version control them, copy them
Export what you already have: Export what you already have:
```bash ```bash
goupkeep export -o monitors.yaml uptop export -o monitors.yaml
``` ```
That gives you a working file you can edit and re-apply: That gives you a working file you can edit and re-apply:
```bash ```bash
goupkeep apply -f monitors.yaml uptop apply -f monitors.yaml
``` ```
That's it. Apply only creates or updates — it won't delete anything unless you tell it to. That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
@@ -184,34 +184,34 @@ All 9 providers work in the YAML. The `settings` map is different per type.
**Export current state:** **Export current state:**
```bash ```bash
goupkeep export -o monitors.yaml # to a file uptop export -o monitors.yaml # to a file
goupkeep export # to stdout uptop export # to stdout
``` ```
**Apply a config:** **Apply a config:**
```bash ```bash
goupkeep apply -f monitors.yaml uptop apply -f monitors.yaml
``` ```
**See what would change first:** **See what would change first:**
```bash ```bash
goupkeep apply -f monitors.yaml --dry-run uptop apply -f monitors.yaml --dry-run
``` ```
**Delete monitors not in the YAML:** **Delete monitors not in the YAML:**
```bash ```bash
goupkeep apply -f monitors.yaml --prune uptop apply -f monitors.yaml --prune
``` ```
Without `--prune`, apply never deletes anything. It only creates and updates. Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:** **Pointing at a different database:**
```bash ```bash
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable" uptop export -db-type postgres -dsn "host=localhost dbname=uptop sslmode=disable"
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..." uptop apply -f monitors.yaml -db-type postgres -dsn "..."
``` ```
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too. Both commands respect the `UPTOP_DB_TYPE` and `UPTOP_DB_DSN` environment variables too.
## How apply works ## How apply works
@@ -230,15 +230,15 @@ If something fails mid-apply, just fix the issue and run it again. It picks up w
```bash ```bash
# set up your monitors in the TUI first, then export # set up your monitors in the TUI first, then export
goupkeep export -o monitors.yaml uptop export -o monitors.yaml
# commit it # commit it
git add monitors.yaml && git commit -m "add monitor config" git add monitors.yaml && git commit -m "add monitor config"
# deploy to another instance # deploy to another instance
scp monitors.yaml prod-server: scp monitors.yaml prod-server:
ssh prod-server goupkeep apply -f monitors.yaml ssh prod-server uptop apply -f monitors.yaml
# or just keep it as a backup you can restore from # or just keep it as a backup you can restore from
goupkeep apply -f monitors.yaml uptop apply -f monitors.yaml
``` ```
+1 -1
View File
@@ -1,4 +1,4 @@
module go-upkeep module gitea.lerkolabs.com/lerko/uptop
go 1.24.4 go 1.24.4
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http" "net/http"
"net/smtp" "net/smtp"
"strconv" "strconv"
@@ -76,7 +76,7 @@ func pagerdutyPayload(routingKey, severity string) PayloadFunc {
"event_action": "trigger", "event_action": "trigger",
"payload": map[string]string{ "payload": map[string]string{
"summary": fmt.Sprintf("%s: %s", title, message), "summary": fmt.Sprintf("%s: %s", title, message),
"source": "go-upkeep", "source": "uptop",
"severity": severity, "severity": severity,
}, },
}) })
@@ -184,7 +184,7 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
} }
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
msg := []byte("To: " + e.To + "\r\n" + msg := []byte("To: " + e.To + "\r\n" +
"Subject: Go-Upkeep: " + title + "\r\n" + "Subject: uptop: " + title + "\r\n" +
"\r\n" + "\r\n" +
message + "\r\n") message + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg)
+1 -1
View File
@@ -3,7 +3,7 @@ package alert
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
+1 -1
View File
@@ -3,7 +3,7 @@ package cluster
import ( import (
"context" "context"
"fmt" "fmt"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"strings" "strings"
"time" "time"
+2 -2
View File
@@ -3,8 +3,8 @@ package cluster
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"log" "log"
"net/http" "net/http"
"sync" "sync"
+2 -2
View File
@@ -2,8 +2,8 @@ package config
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"reflect" "reflect"
"strings" "strings"
) )
+2 -2
View File
@@ -1,8 +1,8 @@
package config package config
import ( import (
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"strings" "strings"
"testing" "testing"
) )
+2 -2
View File
@@ -2,8 +2,8 @@ package config
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"os" "os"
"sort" "sort"
+1 -1
View File
@@ -1,7 +1,7 @@
package config package config
import ( import (
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"testing" "testing"
) )
+1 -1
View File
@@ -3,7 +3,7 @@ package importer
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"os" "os"
"strings" "strings"
) )
+22 -22
View File
@@ -2,8 +2,8 @@ package metrics
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
@@ -16,74 +16,74 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
var b strings.Builder var b strings.Builder
writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == "UP" { if s.Status == "UP" {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.") writeHelp(&b, "uptop_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
for _, s := range sites { for _, s := range sites {
writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds()) writeGauge(&b, "uptop_monitor_latency_seconds", labels(s), s.Latency.Seconds())
} }
writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.") writeHelp(&b, "uptop_monitor_status_code", "gauge", "HTTP response status code of the last check.")
for _, s := range sites { for _, s := range sites {
if s.Type != "http" { if s.Type != "http" {
continue continue
} }
writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode)) writeGauge(&b, "uptop_monitor_status_code", labels(s), float64(s.StatusCode))
} }
writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.") writeHelp(&b, "uptop_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
for _, s := range sites { for _, s := range sites {
if s.LastCheck.IsZero() { if s.LastCheck.IsZero() {
continue continue
} }
writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix())) writeGauge(&b, "uptop_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
} }
writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).") writeHelp(&b, "uptop_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Paused { if s.Paused {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_paused", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).") writeHelp(&b, "uptop_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if eng.GetDisplayStatus(s) == "MAINT" { if eng.GetDisplayStatus(s) == "MAINT" {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_maintenance", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.") writeHelp(&b, "uptop_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
for _, s := range sites { for _, s := range sites {
if !s.HasSSL || s.CertExpiry.IsZero() { if !s.HasSSL || s.CertExpiry.IsZero() {
continue continue
} }
writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix())) writeGauge(&b, "uptop_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
} }
writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.") writeHelp(&b, "uptop_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.") writeHelp(&b, "uptop_monitor_checks_up_total", "counter", "Total number of successful checks.")
for _, s := range sites { for _, s := range sites {
h, ok := eng.GetHistory(s.ID) h, ok := eng.GetHistory(s.ID)
if !ok { if !ok {
continue continue
} }
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks)) writeGauge(&b, "uptop_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks)) writeGauge(&b, "uptop_monitor_checks_up_total", labels(s), float64(h.UpChecks))
} }
writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.") writeHelp(&b, "uptop_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
for _, site := range sites { for _, site := range sites {
probeResults := eng.GetProbeResults(site.ID) probeResults := eng.GetProbeResults(site.ID)
for nodeID, result := range probeResults { for nodeID, result := range probeResults {
@@ -92,7 +92,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
val = 1 val = 1
} }
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID)) nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val)) writeGauge(&b, "uptop_probe_up", nodeLabels, float64(val))
} }
} }
+9 -9
View File
@@ -2,8 +2,8 @@ package metrics
import ( import (
"context" "context"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -94,13 +94,13 @@ func TestMetricsHandler(t *testing.T) {
} }
expected := []string{ expected := []string{
"# HELP upkeep_monitor_up", "# HELP uptop_monitor_up",
"# TYPE upkeep_monitor_up gauge", "# TYPE uptop_monitor_up gauge",
`upkeep_monitor_up{id="1",name="Example",type="http"}`, `uptop_monitor_up{id="1",name="Example",type="http"}`,
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`, `uptop_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP upkeep_monitor_latency_seconds", "# HELP uptop_monitor_latency_seconds",
"# HELP upkeep_monitor_paused", "# HELP uptop_monitor_paused",
"# HELP upkeep_monitor_checks_total", "# HELP uptop_monitor_checks_total",
} }
for _, s := range expected { for _, s := range expected {
if !strings.Contains(body, s) { if !strings.Contains(body, s) {
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import ( import (
"context" "context"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import ( import (
"crypto/tls" "crypto/tls"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"go-upkeep/internal/alert" "gitea.lerkolabs.com/lerko/uptop/internal/alert"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"math/rand/v2" "math/rand/v2"
"net/http" "net/http"
"sync" "sync"
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"sync" "sync"
"testing" "testing"
"time" "time"
+8 -8
View File
@@ -4,11 +4,11 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/importer"
"go-upkeep/internal/metrics" "gitea.lerkolabs.com/lerko/uptop/internal/metrics"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
@@ -59,7 +59,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
<div id="summary" class="summary"></div> <div id="summary" class="summary"></div>
<div id="stale" class="stale-bar"></div> <div id="stale" class="stale-bar"></div>
<div id="cards"></div> <div id="cards"></div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div> <div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by uptop</div>
</div> </div>
<script> <script>
var lastUpdate = null; var lastUpdate = null;
@@ -161,7 +161,7 @@ type ServerConfig struct {
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" { if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
} }
mux := http.NewServeMux() mux := http.NewServeMux()
@@ -193,7 +193,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
// 3. Config Export // 3. Config Export
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized) http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return return
} }
data, err := s.ExportData() data, err := s.ExportData()
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net" "net"
"net/http" "net/http"
"sync" "sync"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"log" "log"
"time" "time"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
package store package store
import ( import (
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"testing" "testing"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
package store package store
import ( import (
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
type Store interface { type Store interface {
+1 -1
View File
@@ -319,7 +319,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
).Title("Gotify Settings").WithHideFunc(func() bool { ).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify" return m.alertFormData.AlertType != "gotify"
}), }),
).WithTheme(huh.ThemeDracula()) ).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init() return m.huhForm.Init()
} }
+3 -3
View File
@@ -2,7 +2,7 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"strconv" "strconv"
"time" "time"
@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")) var maintStyle lipgloss.Style
type maintFormData struct { type maintFormData struct {
Title string Title string
@@ -187,7 +187,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
).Title("Duration").WithHideFunc(func() bool { ).Title("Duration").WithHideFunc(func() bool {
return m.maintFormData.Type == "incident" return m.maintFormData.Type == "incident"
}), }),
).WithTheme(huh.ThemeDracula()) ).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init() return m.huhForm.Init()
} }
+18 -11
View File
@@ -2,12 +2,13 @@ package tui
import ( import (
"fmt" "fmt"
"go-upkeep/internal/models"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -37,10 +38,7 @@ func typeIcon(siteType string, collapsed bool) string {
} }
} }
var siteGroupStyle = lipgloss.NewStyle(). var siteGroupStyle lipgloss.Style
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
type siteFormData struct { type siteFormData struct {
Name string Name string
@@ -340,10 +338,10 @@ func (m Model) viewSitesTab() string {
if len(m.sites) == 0 { if len(m.sites) == 0 {
welcome := lipgloss.NewStyle(). welcome := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")). BorderForeground(m.theme.Accent).
Padding(1, 3). Padding(1, 3).
Render( Render(
titleStyle.Render("Go-Upkeep") + "\n\n" + titleStyle.Render("uptop") + "\n\n" +
"No monitors configured yet.\n\n" + "No monitors configured yet.\n\n" +
subtleStyle.Render("[n] Add your first monitor"), subtleStyle.Render("[n] Add your first monitor"),
) )
@@ -509,7 +507,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Required for HTTP monitors"). Description("Required for HTTP monitors").
Value(&m.siteFormData.URL). Value(&m.siteFormData.URL).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" { if m.siteFormData.SiteType != "http" {
return nil return nil
} }
if s == "" { if s == "" {
@@ -555,12 +553,15 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Target port for TCP port monitors"). Description("Target port for TCP port monitors").
Value(&m.siteFormData.Port). Value(&m.siteFormData.Port).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType != "port" {
return nil
}
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
if err != nil { if err != nil {
return fmt.Errorf("must be a number") return fmt.Errorf("must be a number")
} }
if v < 0 || v > 65535 { if v < 1 || v > 65535 {
return fmt.Errorf("port must be 0-65535") return fmt.Errorf("port must be 1-65535")
} }
return nil return nil
}), }),
@@ -615,6 +616,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("7"). Placeholder("7").
Value(&m.siteFormData.Threshold). Value(&m.siteFormData.Threshold).
Validate(func(s string) error { Validate(func(s string) error {
if !m.siteFormData.CheckSSL {
return nil
}
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
if err != nil { if err != nil {
return fmt.Errorf("must be a number") return fmt.Errorf("must be a number")
@@ -628,6 +632,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("0"). Placeholder("0").
Value(&m.siteFormData.Retries). Value(&m.siteFormData.Retries).
Validate(func(s string) error { Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s) v, err := strconv.Atoi(s)
if err != nil { if err != nil {
return fmt.Errorf("must be a number") return fmt.Errorf("must be a number")
@@ -642,7 +649,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
).Title("Advanced").WithHideFunc(func() bool { ).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group" return m.siteFormData.SiteType == "group"
}), }),
).WithTheme(huh.ThemeDracula()) ).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init() return m.huhForm.Init()
} }
+1 -1
View File
@@ -94,7 +94,7 @@ func (m *Model) initUserHuhForm() tea.Cmd {
huh.NewOption("Admin", "admin"), huh.NewOption("Admin", "admin"),
).Value(&m.userFormData.Role), ).Value(&m.userFormData.Role),
).Title("SSH Access"), ).Title("SSH Access"),
).WithTheme(huh.ThemeDracula()) ).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init() return m.huhForm.Init()
} }
+5 -19
View File
@@ -6,25 +6,11 @@ import (
) )
var ( var (
tableHeaderStyle = lipgloss.NewStyle(). tableHeaderStyle lipgloss.Style
Foreground(lipgloss.Color("#7D56F4")). tableCellStyle lipgloss.Style
Bold(true). tableSelectedStyle lipgloss.Style
Padding(0, 1) tableBorderStyle lipgloss.Style
tableZebraStyle lipgloss.Style
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
tableZebraStyle = lipgloss.NewStyle().
Padding(0, 1).
Background(lipgloss.Color("#1a1a2e"))
) )
type StyleOverride func(row, col int) *lipgloss.Style type StyleOverride func(row, col int) *lipgloss.Style
+191
View File
@@ -0,0 +1,191 @@
package tui
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type Theme struct {
Name string
// Base layers
Bg lipgloss.Color
Surface lipgloss.Color
Panel lipgloss.Color
Border lipgloss.Color
// Text
Fg lipgloss.Color
Muted lipgloss.Color
Subtle lipgloss.Color
// Semantic
Success lipgloss.Color
Warning lipgloss.Color
Danger lipgloss.Color
Info lipgloss.Color
Accent lipgloss.Color
Purple lipgloss.Color
// Table
ZebraBg lipgloss.Color
// Selection
SelectedFg lipgloss.Color
SelectedBg lipgloss.Color
}
var themes = []Theme{
themeFlexokiDark,
themeTokyoNight,
themeCatppuccinMocha,
themeNord,
themeGruvbox,
}
var themeFlexokiDark = Theme{
Name: "Flexoki Dark",
Bg: "#1C1B1A",
Surface: "#282726",
Panel: "#343331",
Border: "#575653",
Fg: "#CECDC3",
Muted: "#878580",
Subtle: "#6F6E69",
Success: "#879A39",
Warning: "#D0A215",
Danger: "#D14D41",
Info: "#4385BE",
Accent: "#3AA99F",
Purple: "#8B7EC8",
ZebraBg: "#222120",
SelectedFg: "#FFFCF0",
SelectedBg: "#403E3C",
}
var themeTokyoNight = Theme{
Name: "Tokyo Night",
Bg: "#1a1b26",
Surface: "#24283b",
Panel: "#292e42",
Border: "#3b4261",
Fg: "#c0caf5",
Muted: "#a9b1d6",
Subtle: "#565f89",
Success: "#9ece6a",
Warning: "#e0af68",
Danger: "#f7768e",
Info: "#7aa2f7",
Accent: "#7dcfff",
Purple: "#bb9af7",
ZebraBg: "#1c1d28",
SelectedFg: "#c0caf5",
SelectedBg: "#292e42",
}
var themeGruvbox = Theme{
Name: "Gruvbox",
Bg: "#282828",
Surface: "#3c3836",
Panel: "#504945",
Border: "#665c54",
Fg: "#ebdbb2",
Muted: "#bdae93",
Subtle: "#7c6f64",
Success: "#b8bb26",
Warning: "#fabd2f",
Danger: "#fb4934",
Info: "#83a598",
Accent: "#8ec07c",
Purple: "#d3869b",
ZebraBg: "#2a2a2a",
SelectedFg: "#fbf1c7",
SelectedBg: "#504945",
}
var themeCatppuccinMocha = Theme{
Name: "Catppuccin Mocha",
Bg: "#1e1e2e",
Surface: "#313244",
Panel: "#45475a",
Border: "#585b70",
Fg: "#cdd6f4",
Muted: "#a6adc8",
Subtle: "#6c7086",
Success: "#a6e3a1",
Warning: "#f9e2af",
Danger: "#f38ba8",
Info: "#89b4fa",
Accent: "#94e2d5",
Purple: "#cba6f7",
ZebraBg: "#232334",
SelectedFg: "#cdd6f4",
SelectedBg: "#45475a",
}
var themeNord = Theme{
Name: "Nord",
Bg: "#2e3440",
Surface: "#3b4252",
Panel: "#434c5e",
Border: "#4c566a",
Fg: "#d8dee9",
Muted: "#d8dee9",
Subtle: "#4c566a",
Success: "#a3be8c",
Warning: "#ebcb8b",
Danger: "#bf616a",
Info: "#81a1c1",
Accent: "#88c0d0",
Purple: "#b48ead",
ZebraBg: "#323845",
SelectedFg: "#eceff4",
SelectedBg: "#434c5e",
}
func (t Theme) HuhTheme() *huh.Theme {
ht := huh.ThemeBase()
ht.Focused.Base = ht.Focused.Base.BorderForeground(t.Border)
ht.Focused.Card = ht.Focused.Base
ht.Focused.Title = ht.Focused.Title.Foreground(t.Accent).Bold(true)
ht.Focused.NoteTitle = ht.Focused.NoteTitle.Foreground(t.Accent).Bold(true).MarginBottom(1)
ht.Focused.Description = ht.Focused.Description.Foreground(t.Muted)
ht.Focused.ErrorIndicator = ht.Focused.ErrorIndicator.Foreground(t.Danger)
ht.Focused.ErrorMessage = ht.Focused.ErrorMessage.Foreground(t.Danger)
ht.Focused.SelectSelector = ht.Focused.SelectSelector.Foreground(t.Purple)
ht.Focused.NextIndicator = ht.Focused.NextIndicator.Foreground(t.Purple)
ht.Focused.PrevIndicator = ht.Focused.PrevIndicator.Foreground(t.Purple)
ht.Focused.Option = ht.Focused.Option.Foreground(t.Fg)
ht.Focused.MultiSelectSelector = ht.Focused.MultiSelectSelector.Foreground(t.Purple)
ht.Focused.SelectedOption = ht.Focused.SelectedOption.Foreground(t.Success)
ht.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(t.Success).SetString("✓ ")
ht.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.Subtle).SetString("• ")
ht.Focused.UnselectedOption = ht.Focused.UnselectedOption.Foreground(t.Fg)
ht.Focused.FocusedButton = ht.Focused.FocusedButton.Foreground(t.Bg).Background(t.Accent)
ht.Focused.Next = ht.Focused.FocusedButton
ht.Focused.BlurredButton = ht.Focused.BlurredButton.Foreground(t.Fg).Background(t.Surface)
ht.Focused.TextInput.Cursor = ht.Focused.TextInput.Cursor.Foreground(t.Accent)
ht.Focused.TextInput.Placeholder = ht.Focused.TextInput.Placeholder.Foreground(t.Subtle)
ht.Focused.TextInput.Prompt = ht.Focused.TextInput.Prompt.Foreground(t.Purple)
ht.Blurred = ht.Focused
ht.Blurred.Base = ht.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
ht.Blurred.Card = ht.Blurred.Base
ht.Blurred.NextIndicator = lipgloss.NewStyle()
ht.Blurred.PrevIndicator = lipgloss.NewStyle()
ht.Group.Title = ht.Focused.Title
ht.Group.Description = ht.Focused.Description
return ht
}
func themeByName(name string) Theme {
for _, t := range themes {
if t.Name == name {
return t
}
}
return themes[0]
}
+57 -17
View File
@@ -3,9 +3,9 @@ package tui
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"go-upkeep/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/monitor" "gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"math" "math"
"sort" "sort"
"strings" "strings"
@@ -20,16 +20,34 @@ import (
) )
var ( var (
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"}) subtleStyle lipgloss.Style
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}) specialStyle lipgloss.Style
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"}) warnStyle lipgloss.Style
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"}) dangerStyle lipgloss.Style
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true) titleStyle lipgloss.Style
activeTab lipgloss.Style
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.Style
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"})
) )
func applyTheme(t Theme) {
subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle)
specialStyle = lipgloss.NewStyle().Foreground(t.Success)
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg)
tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border)
tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg)
siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent)
maintStyle = lipgloss.NewStyle().Foreground(t.Purple)
}
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const ( const (
@@ -84,6 +102,8 @@ type Model struct {
collapsed map[int]bool collapsed map[int]bool
store store.Store store store.Store
engine *monitor.Engine engine *monitor.Engine
theme Theme
themeIndex int
// harmonica animation state // harmonica animation state
pulseSpring harmonica.Spring pulseSpring harmonica.Spring
@@ -107,6 +127,19 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
z := zone.New() z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4) spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(s) collapsed := loadCollapsed(s)
themeName, _ := s.GetPreference("theme")
theme := themeByName(themeName)
themeIdx := 0
for i, t := range themes {
if t.Name == theme.Name {
themeIdx = i
break
}
}
applyTheme(theme)
return Model{ return Model{
state: stateDashboard, state: stateDashboard,
logViewport: vpLogs, logViewport: vpLogs,
@@ -117,6 +150,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
zones: z, zones: z,
pulseSpring: spring, pulseSpring: spring,
collapsed: collapsed, collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
} }
} }
@@ -458,6 +493,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshData() m.refreshData()
} }
} }
case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex]
applyTheme(m.theme)
_ = m.store.SetPreference("theme", m.theme.Name)
case "d", "backspace": case "d", "backspace":
if m.currentTab == 0 && len(m.sites) > 0 { if m.currentTab == 0 && len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID m.deleteID = m.sites[m.cursor].ID
@@ -723,7 +763,7 @@ func (m Model) View() string {
hint := subtleStyle.Render("[y] Confirm [n] Cancel") hint := subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#F25D94")). BorderForeground(m.theme.Danger).
Padding(1, 3). Padding(1, 3).
Render(msg + "\n\n" + hint) Render(msg + "\n\n" + hint)
return lipgloss.NewStyle().Padding(2, 4).Render(box) return lipgloss.NewStyle().Padding(2, 4).Render(box)
@@ -875,19 +915,19 @@ func (m Model) viewDashboard() string {
var footer string var footer string
if m.filterMode { if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│") cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear") footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
} else { } else {
var keys string var keys string
switch m.currentTab { switch m.currentTab {
case 0: case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit" keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
case 4: case 4:
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5: case 5:
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit" keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
default: default:
keys = "[Tab]Switch [q]Quit" keys = "[T]Theme [Tab]Switch [q]Quit"
} }
footer = "\n" + statusLine + " " + subtleStyle.Render(keys) footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
if m.filterText != "" && m.currentTab == 0 { if m.filterText != "" && m.currentTab == 0 {