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:
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
run: apk add --no-cache gcc musl-dev
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/go-upkeep
images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop
tags: |
# This turns git tag "v1.0.0" into docker tag "1.0.0"
type=semver,pattern={{version}}
+2 -5
View File
@@ -26,8 +26,8 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go
/goupkeep
upkeep.db
/uptop
uptop.db
.ssh
@@ -35,8 +35,5 @@ authorized_keys
tmp
# Old repo
/go-upkeep/
*.local.json
*.local.md
+1 -1
View File
@@ -3,7 +3,7 @@
## Development
```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
```
+7 -7
View File
@@ -9,7 +9,7 @@ ENV CGO_ENABLED=1
ARG VERSION=dev
ARG COMMIT=none
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 ---
FROM alpine:latest
@@ -17,15 +17,15 @@ WORKDIR /app
RUN apk add --no-cache ca-certificates openssh-client
RUN mkdir /data
COPY --from=builder /app/go-upkeep .
COPY --from=builder /app/uptop .
# 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
ENV UPTOP_DB_TYPE=sqlite
ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPTOP_KEYS=/data/authorized_keys
ENV UPTOP_PORT=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`.
@@ -18,14 +18,14 @@ Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upk
## Quick start
```bash
go run cmd/goupkeep/main.go
go run cmd/uptop/main.go
ssh -p 23234 localhost
```
Seed some demo data to see it in action:
```bash
go run cmd/goupkeep/main.go -demo
go run cmd/uptop/main.go -demo
```
## Install
@@ -33,34 +33,34 @@ go run cmd/goupkeep/main.go -demo
### From source
```bash
go install gitea.lerkolabs.com/lerko/uptime/cmd/goupkeep@latest
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
```
### Docker
```bash
docker pull lerko/go-upkeep:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/go-upkeep
docker pull lerko/uptop:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
```
### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases).
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
## Config as code
Export your current monitors:
```bash
goupkeep export -o monitors.yaml
uptop export -o monitors.yaml
```
Apply a config file:
```bash
goupkeep apply -f monitors.yaml
goupkeep apply -f monitors.yaml --dry-run # see what would change
goupkeep apply -f monitors.yaml --prune # delete anything not in the YAML
uptop apply -f monitors.yaml
uptop apply -f monitors.yaml --dry-run # see what would change
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.
@@ -81,28 +81,28 @@ services:
- ./data:/data
- ./ssh_keys:/app/.ssh
environment:
- UPKEEP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_CLUSTER_SECRET=change-me
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_STATUS_ENABLED=true
- 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
| Variable | Default | What it does |
|---|---|---|
| `UPKEEP_PORT` | `23234` | SSH server port |
| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string |
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title |
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes |
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
| `UPTOP_PORT` | `23234` | SSH server port |
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPTOP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
| `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
| `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPTOP_PEER_URL` | | Leader URL for follower nodes |
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
## Migrating from Uptime Kuma
+32 -32
View File
@@ -5,14 +5,14 @@ import (
"errors"
"flag"
"fmt"
"go-upkeep/internal/cluster"
"go-upkeep/internal/config"
"go-upkeep/internal/importer"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/server"
"go-upkeep/internal/store"
"go-upkeep/internal/tui"
"gitea.lerkolabs.com/lerko/uptop/internal/cluster"
"gitea.lerkolabs.com/lerko/uptop/internal/config"
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/server"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
"log"
"os"
"os/signal"
@@ -54,9 +54,9 @@ func main() {
func printVersion() {
if version == "dev" {
fmt.Println("go-upkeep dev")
fmt.Println("uptop dev")
} 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)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
if *filePath == "" {
@@ -124,8 +124,8 @@ func runApply(args []string) {
func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn)
@@ -145,7 +145,7 @@ func runExport(args []string) {
func runServe(args []string) {
portVal := 23234
dbType := "sqlite"
dbDSN := "upkeep.db"
dbDSN := "uptop.db"
httpPort := 8080
enableStatus := false
statusTitle := "System Status"
@@ -153,50 +153,50 @@ func runServe(args []string) {
clusterPeer := ""
clusterKey := ""
if v := os.Getenv("UPKEEP_PORT"); v != "" {
if v := os.Getenv("UPTOP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
portVal = p
}
}
if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" {
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
dbType = v
}
if v := os.Getenv("UPKEEP_DB_DSN"); v != "" {
if v := os.Getenv("UPTOP_DB_DSN"); 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 {
httpPort = p
}
}
if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" {
if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
enableStatus = true
}
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" {
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
statusTitle = v
}
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" {
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
clusterMode = v
}
if v := os.Getenv("UPKEEP_PEER_URL"); v != "" {
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
clusterPeer = v
}
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" {
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
clusterKey = v
}
nodeID := os.Getenv("UPKEEP_NODE_ID")
nodeName := os.Getenv("UPKEEP_NODE_NAME")
nodeRegion := os.Getenv("UPKEEP_NODE_REGION")
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY")
nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
if clusterMode == "probe" {
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)
}
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)
}
@@ -270,7 +270,7 @@ func runServe(args []string) {
}
eng := monitor.NewEngine(s)
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true)
}
if aggStrategy != "" {
@@ -305,7 +305,7 @@ func runServe(args []string) {
fmt.Printf("Error: %v\n", err)
}
} else {
fmt.Println("Go-Upkeep running in HEADLESS mode")
fmt.Println("uptop running in HEADLESS mode")
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-done
+21 -21
View File
@@ -4,21 +4,21 @@ services:
# -------------------------
leader:
build: .
container_name: upkeep-leader
container_name: uptop-leader
ports:
- "23234:23234" # SSH
- "8080:8080" # HTTP
environment:
- UPKEEP_DB_TYPE=postgres
- UPTOP_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
- UPTOP_DB_DSN=postgres://devuser:devpass@leader-db:5432/uptop_dev?sslmode=disable
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Leader Node
# Cluster Config
- UPKEEP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=mysecret
- UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=mysecret
depends_on:
- leader-db
stdin_open: true
@@ -26,11 +26,11 @@ services:
leader-db:
image: postgres:15-alpine
container_name: upkeep-leader-db
container_name: uptop-leader-db
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev
POSTGRES_DB: uptop_dev
volumes:
- ./tmp/leader-data:/var/lib/postgresql/data
@@ -39,23 +39,23 @@ services:
# -------------------------
follower:
build: .
container_name: upkeep-follower
container_name: uptop-follower
ports:
- "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (Mapped to different host port)
environment:
- UPKEEP_DB_TYPE=postgres
- UPTOP_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
- UPTOP_DB_DSN=postgres://devuser:devpass@follower-db:5432/uptop_dev?sslmode=disable
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Follower Node
# Cluster Config
- UPKEEP_CLUSTER_MODE=follower
- UPKEEP_CLUSTER_SECRET=mysecret
- UPTOP_CLUSTER_MODE=follower
- UPTOP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally
- UPKEEP_PEER_URL=http://leader:8080
- UPTOP_PEER_URL=http://leader:8080
depends_on:
- follower-db
stdin_open: true
@@ -63,10 +63,10 @@ services:
follower-db:
image: postgres:15-alpine
container_name: upkeep-follower-db
container_name: uptop-follower-db
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev
POSTGRES_DB: uptop_dev
volumes:
- ./tmp/follower-data:/var/lib/postgresql/data
+8 -8
View File
@@ -4,19 +4,19 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: upkeep-dev
container_name: uptop-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
- UPTOP_DB_TYPE=postgres
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
# --- Web Server Configuration (Phase 4) ---
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Dev Infrastructure Status
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
depends_on:
- postgres
stdin_open: true # Required for 'docker attach' (Local Admin Console)
@@ -25,11 +25,11 @@ services:
# The Database
postgres:
image: postgres:15-alpine
container_name: upkeep-postgres
container_name: uptop-postgres
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev
POSTGRES_DB: uptop_dev
ports:
- "5432:5432" # Expose for external DB tools (DBeaver, etc.)
volumes:
+16 -16
View File
@@ -2,10 +2,10 @@ services:
leader:
build: .
environment:
- UPKEEP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=changeme
- UPKEEP_AGG_STRATEGY=any-down
- UPKEEP_STATUS_ENABLED=true
- UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=changeme
- UPTOP_AGG_STRATEGY=any-down
- UPTOP_STATUS_ENABLED=true
ports:
- "8080:8080"
- "23234:23234"
@@ -13,23 +13,23 @@ services:
probe-us-east:
build: .
environment:
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=us-east-1
- UPKEEP_NODE_NAME=US East Probe
- UPKEEP_NODE_REGION=us-east
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=us-east-1
- UPTOP_NODE_NAME=US East Probe
- UPTOP_NODE_REGION=us-east
- UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme
depends_on:
- leader
probe-eu-west:
build: .
environment:
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=eu-west-1
- UPKEEP_NODE_NAME=EU West Probe
- UPKEEP_NODE_REGION=eu-west
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=eu-west-1
- UPTOP_NODE_NAME=EU West Probe
- UPTOP_NODE_REGION=eu-west
- UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme
depends_on:
- leader
+6 -6
View File
@@ -3,16 +3,16 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: upkeep
container_name: uptop
restart: unless-stopped
ports:
- "23234:23234"
- "8080:8080"
environment:
- UPKEEP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=System Status
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=System Status
volumes:
- ./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:
```bash
goupkeep export -o monitors.yaml
uptop export -o monitors.yaml
```
That gives you a working file you can edit and re-apply:
```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.
@@ -184,34 +184,34 @@ All 9 providers work in the YAML. The `settings` map is different per type.
**Export current state:**
```bash
goupkeep export -o monitors.yaml # to a file
goupkeep export # to stdout
uptop export -o monitors.yaml # to a file
uptop export # to stdout
```
**Apply a config:**
```bash
goupkeep apply -f monitors.yaml
uptop apply -f monitors.yaml
```
**See what would change first:**
```bash
goupkeep apply -f monitors.yaml --dry-run
uptop apply -f monitors.yaml --dry-run
```
**Delete monitors not in the YAML:**
```bash
goupkeep apply -f monitors.yaml --prune
uptop apply -f monitors.yaml --prune
```
Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:**
```bash
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable"
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..."
uptop export -db-type postgres -dsn "host=localhost dbname=uptop sslmode=disable"
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
@@ -230,15 +230,15 @@ If something fails mid-apply, just fix the issue and run it again. It picks up w
```bash
# set up your monitors in the TUI first, then export
goupkeep export -o monitors.yaml
uptop export -o monitors.yaml
# commit it
git add monitors.yaml && git commit -m "add monitor config"
# deploy to another instance
scp monitors.yaml prod-server:
ssh prod-server goupkeep apply -f monitors.yaml
ssh prod-server uptop apply -f monitors.yaml
# 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
+3 -3
View File
@@ -5,7 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http"
"net/smtp"
"strconv"
@@ -76,7 +76,7 @@ func pagerdutyPayload(routingKey, severity string) PayloadFunc {
"event_action": "trigger",
"payload": map[string]string{
"summary": fmt.Sprintf("%s: %s", title, message),
"source": "go-upkeep",
"source": "uptop",
"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)
msg := []byte("To: " + e.To + "\r\n" +
"Subject: Go-Upkeep: " + title + "\r\n" +
"Subject: uptop: " + title + "\r\n" +
"\r\n" +
message + "\r\n")
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 (
"context"
"encoding/json"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http"
"net/http/httptest"
"testing"
+1 -1
View File
@@ -3,7 +3,7 @@ package cluster
import (
"context"
"fmt"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http"
"strings"
"time"
+2 -2
View File
@@ -3,8 +3,8 @@ package cluster
import (
"context"
"encoding/json"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http"
"net/http/httptest"
"sync"
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"log"
"net/http"
"sync"
+2 -2
View File
@@ -2,8 +2,8 @@ package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"reflect"
"strings"
)
+2 -2
View File
@@ -1,8 +1,8 @@
package config
import (
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"strings"
"testing"
)
+2 -2
View File
@@ -2,8 +2,8 @@ package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"os"
"sort"
+1 -1
View File
@@ -1,7 +1,7 @@
package config
import (
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"testing"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package importer
import (
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"os"
"strings"
)
+22 -22
View File
@@ -2,8 +2,8 @@ package metrics
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http"
"sort"
"strings"
@@ -16,74 +16,74 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
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 {
val := 0
if s.Status == "UP" {
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 {
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 {
if s.Type != "http" {
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 {
if s.LastCheck.IsZero() {
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 {
val := 0
if s.Paused {
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 {
val := 0
if eng.GetDisplayStatus(s) == "MAINT" {
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 {
if !s.HasSSL || s.CertExpiry.IsZero() {
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, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.")
writeHelp(&b, "uptop_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "uptop_monitor_checks_up_total", "counter", "Total number of successful checks.")
for _, s := range sites {
h, ok := eng.GetHistory(s.ID)
if !ok {
continue
}
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks))
writeGauge(&b, "uptop_monitor_checks_total", labels(s), float64(h.TotalChecks))
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 {
probeResults := eng.GetProbeResults(site.ID)
for nodeID, result := range probeResults {
@@ -92,7 +92,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
val = 1
}
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val))
writeGauge(&b, "uptop_probe_up", nodeLabels, float64(val))
}
}
+9 -9
View File
@@ -2,8 +2,8 @@ package metrics
import (
"context"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http"
"net/http/httptest"
"strings"
@@ -94,13 +94,13 @@ func TestMetricsHandler(t *testing.T) {
}
expected := []string{
"# HELP upkeep_monitor_up",
"# TYPE upkeep_monitor_up gauge",
`upkeep_monitor_up{id="1",name="Example",type="http"}`,
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP upkeep_monitor_latency_seconds",
"# HELP upkeep_monitor_paused",
"# HELP upkeep_monitor_checks_total",
"# HELP uptop_monitor_up",
"# TYPE uptop_monitor_up gauge",
`uptop_monitor_up{id="1",name="Example",type="http"}`,
`uptop_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP uptop_monitor_latency_seconds",
"# HELP uptop_monitor_paused",
"# HELP uptop_monitor_checks_total",
}
for _, s := range expected {
if !strings.Contains(body, s) {
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import (
"context"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net"
"net/http"
"strconv"
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import (
"crypto/tls"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net"
"net/http"
"net/http/httptest"
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"context"
"crypto/tls"
"fmt"
"go-upkeep/internal/alert"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"math/rand/v2"
"net/http"
"sync"
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import (
"fmt"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"sync"
"testing"
"time"
+8 -8
View File
@@ -4,11 +4,11 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"go-upkeep/internal/importer"
"go-upkeep/internal/metrics"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"html/template"
"log"
"net/http"
@@ -59,7 +59,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
<div id="summary" class="summary"></div>
<div id="stale" class="stale-bar"></div>
<div id="cards"></div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by uptop</div>
</div>
<script>
var lastUpdate = null;
@@ -161,7 +161,7 @@ type ServerConfig struct {
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
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()
@@ -193,7 +193,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
// 3. Config Export
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
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
}
data, err := s.ExportData()
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net"
"net/http"
"sync"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"log"
"time"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package store
import (
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"testing"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package store
import (
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
type Store interface {
+1 -1
View File
@@ -319,7 +319,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify"
}),
).WithTheme(huh.ThemeDracula())
).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init()
}
+3 -3
View File
@@ -2,7 +2,7 @@ package tui
import (
"fmt"
"go-upkeep/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"strconv"
"time"
@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss"
)
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))
var maintStyle lipgloss.Style
type maintFormData struct {
Title string
@@ -187,7 +187,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
).Title("Duration").WithHideFunc(func() bool {
return m.maintFormData.Type == "incident"
}),
).WithTheme(huh.ThemeDracula())
).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init()
}
+18 -11
View File
@@ -2,12 +2,13 @@ package tui
import (
"fmt"
"go-upkeep/internal/models"
"net/url"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -37,10 +38,7 @@ func typeIcon(siteType string, collapsed bool) string {
}
}
var siteGroupStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
var siteGroupStyle lipgloss.Style
type siteFormData struct {
Name string
@@ -340,10 +338,10 @@ func (m Model) viewSitesTab() string {
if len(m.sites) == 0 {
welcome := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
BorderForeground(m.theme.Accent).
Padding(1, 3).
Render(
titleStyle.Render("Go-Upkeep") + "\n\n" +
titleStyle.Render("uptop") + "\n\n" +
"No monitors configured yet.\n\n" +
subtleStyle.Render("[n] Add your first monitor"),
)
@@ -509,7 +507,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Required for HTTP monitors").
Value(&m.siteFormData.URL).
Validate(func(s string) error {
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
if m.siteFormData.SiteType != "http" {
return nil
}
if s == "" {
@@ -555,12 +553,15 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Target port for TCP port monitors").
Value(&m.siteFormData.Port).
Validate(func(s string) error {
if m.siteFormData.SiteType != "port" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 0 || v > 65535 {
return fmt.Errorf("port must be 0-65535")
if v < 1 || v > 65535 {
return fmt.Errorf("port must be 1-65535")
}
return nil
}),
@@ -615,6 +616,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("7").
Value(&m.siteFormData.Threshold).
Validate(func(s string) error {
if !m.siteFormData.CheckSSL {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -628,6 +632,9 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("0").
Value(&m.siteFormData.Retries).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -642,7 +649,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
).WithTheme(huh.ThemeDracula())
).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init()
}
+1 -1
View File
@@ -94,7 +94,7 @@ func (m *Model) initUserHuhForm() tea.Cmd {
huh.NewOption("Admin", "admin"),
).Value(&m.userFormData.Role),
).Title("SSH Access"),
).WithTheme(huh.ThemeDracula())
).WithTheme(m.theme.HuhTheme())
return m.huhForm.Init()
}
+5 -19
View File
@@ -6,25 +6,11 @@ import (
)
var (
tableHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
tableZebraStyle = lipgloss.NewStyle().
Padding(0, 1).
Background(lipgloss.Color("#1a1a2e"))
tableHeaderStyle lipgloss.Style
tableCellStyle lipgloss.Style
tableSelectedStyle lipgloss.Style
tableBorderStyle lipgloss.Style
tableZebraStyle 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 (
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"math"
"sort"
"strings"
@@ -20,16 +20,34 @@ import (
)
var (
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"})
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"})
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"})
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"})
subtleStyle lipgloss.Style
specialStyle lipgloss.Style
warnStyle lipgloss.Style
dangerStyle lipgloss.Style
titleStyle lipgloss.Style
activeTab lipgloss.Style
inactiveTab lipgloss.Style
)
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{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
@@ -84,6 +102,8 @@ type Model struct {
collapsed map[int]bool
store store.Store
engine *monitor.Engine
theme Theme
themeIndex int
// harmonica animation state
pulseSpring harmonica.Spring
@@ -107,6 +127,19 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
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{
state: stateDashboard,
logViewport: vpLogs,
@@ -117,6 +150,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
}
}
@@ -458,6 +493,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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":
if m.currentTab == 0 && len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
@@ -723,7 +763,7 @@ func (m Model) View() string {
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#F25D94")).
BorderForeground(m.theme.Danger).
Padding(1, 3).
Render(msg + "\n\n" + hint)
return lipgloss.NewStyle().Padding(2, 4).Render(box)
@@ -875,19 +915,19 @@ func (m Model) viewDashboard() string {
var footer string
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")
} else {
var keys string
switch m.currentTab {
case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
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:
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
default:
keys = "[Tab]Switch [q]Quit"
keys = "[T]Theme [Tab]Switch [q]Quit"
}
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
if m.filterText != "" && m.currentTab == 0 {