Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
dbd519c121
|
|||
|
b32145fb58
|
|||
|
47d3b0e68f
|
|||
|
8fd13fefbf
|
|||
|
974c4b61ea
|
|||
|
d50a5159d4
|
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
# ─── uptop configuration ───────────────────────────────────
|
# ─── uptop configuration ───────────────────────────────────
|
||||||
# Copy to .env and edit. Only uncomment what you need.
|
# Export in your environment or pass via docker run --env-file.\n# Only uncomment what you need.
|
||||||
|
|
||||||
# ─── Core ──────────────────────────────────────────────────
|
# ─── Core ──────────────────────────────────────────────────
|
||||||
UPTOP_PORT=23234 # SSH server port
|
UPTOP_PORT=23234 # SSH server port
|
||||||
@@ -40,3 +40,5 @@ UPTOP_DB_DSN=/data/uptop.db # File path (SQLite) or connection string (Postgre
|
|||||||
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
|
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
|
||||||
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
|
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
|
||||||
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
|
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
|
||||||
|
# UPTOP_TRUSTED_PROXIES= # Comma-separated CIDRs/IPs for X-Forwarded-For trust
|
||||||
|
# UPTOP_MAINT_RETENTION=168h # How long ended maintenance windows are kept
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:
|
|||||||
label: What happened?
|
label: What happened?
|
||||||
description: Include what you expected to happen instead.
|
description: Include what you expected to happen instead.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
When I run `uptop serve`, the TUI crashes after 10 seconds.
|
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||||
I expected it to keep running and display monitor status.
|
I expected it to keep running and display monitor status.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
@@ -25,7 +25,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Steps to reproduce
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Run `uptop serve`
|
1. Run `uptop`
|
||||||
2. Wait ~10 seconds
|
2. Wait ~10 seconds
|
||||||
3. TUI crashes with panic
|
3. TUI crashes with panic
|
||||||
validations:
|
validations:
|
||||||
@@ -37,7 +37,7 @@ body:
|
|||||||
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||||
render: shell
|
render: shell
|
||||||
placeholder: |
|
placeholder: |
|
||||||
uptop version 2026.06.1
|
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||||
OS: Debian 13
|
OS: Debian 13
|
||||||
Terminal: Ghostty
|
Terminal: Ghostty
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ jobs:
|
|||||||
version: "~> v2"
|
version: "~> v2"
|
||||||
args: release --clean --release-notes=/tmp/release-notes.md
|
args: release --clean --release-notes=/tmp/release-notes.md
|
||||||
env:
|
env:
|
||||||
|
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
||||||
GORELEASER_FORCE_TOKEN: gitea
|
GORELEASER_FORCE_TOKEN: gitea
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
GITEA_API_URL: http://gitea:3000/api/v1
|
GITEA_API_URL: http://gitea:3000/api/v1
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ authorized_keys
|
|||||||
tmp
|
tmp
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
|
data/
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-2
@@ -23,14 +23,13 @@ RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
|
|||||||
COPY --from=builder /app/uptop .
|
COPY --from=builder /app/uptop .
|
||||||
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|
||||||
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
|
||||||
ENV UPTOP_DB_TYPE=sqlite
|
ENV UPTOP_DB_TYPE=sqlite
|
||||||
ENV UPTOP_DB_DSN=/data/uptop.db
|
ENV UPTOP_DB_DSN=/data/uptop.db
|
||||||
ENV UPTOP_KEYS=/data/authorized_keys
|
ENV UPTOP_KEYS=/data/authorized_keys
|
||||||
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
||||||
ENV UPTOP_PORT=23234
|
ENV UPTOP_PORT=23234
|
||||||
|
|
||||||
EXPOSE 23234
|
EXPOSE 8080 23234
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:8080/api/health || exit 1
|
CMD wget -qO- http://localhost:8080/api/health || exit 1
|
||||||
USER uptop
|
USER uptop
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
|
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||||
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
||||||
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
|
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
|
||||||
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
|
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
|
||||||
@@ -27,7 +27,7 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
|||||||
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
|
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
|
||||||
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
|
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
|
||||||
- **HA clustering** — leader/follower with automatic failover
|
- **HA clustering** — leader/follower with automatic failover
|
||||||
- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana
|
- **Prometheus metrics** — `/metrics` endpoint (`UPTOP_METRICS_PUBLIC=true` to expose without auth)
|
||||||
- **Public status page** — HTML + JSON, toggle with an env var
|
- **Public status page** — HTML + JSON, toggle with an env var
|
||||||
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
||||||
- **Uptime Kuma import** — migrate from Kuma with one command
|
- **Uptime Kuma import** — migrate from Kuma with one command
|
||||||
@@ -146,6 +146,8 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
|||||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
||||||
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
|
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
|
||||||
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
||||||
|
| `UPTOP_METRICS_PUBLIC` | `false` | Expose `/metrics` without auth |
|
||||||
|
| `UPTOP_MAINT_RETENTION` | `168h` | How long ended maintenance windows are kept |
|
||||||
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
|
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
|
||||||
|
|
||||||
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
|
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
|
||||||
@@ -179,7 +181,7 @@ uptop prunes its own history in the background — no external cleanup jobs need
|
|||||||
| Check history | newest 1,000 checks per monitor |
|
| Check history | newest 1,000 checks per monitor |
|
||||||
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
|
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
|
||||||
| Logs | newest 200 entries |
|
| Logs | newest 200 entries |
|
||||||
| Maintenance windows | 7 days after they end (configurable) |
|
| Maintenance windows | 7 days after they end (`UPTOP_MAINT_RETENTION`) |
|
||||||
|
|
||||||
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
|
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
|
||||||
|
|
||||||
|
|||||||
+25
-5
@@ -77,17 +77,37 @@ func main() {
|
|||||||
case "export":
|
case "export":
|
||||||
runExport(os.Args[2:])
|
runExport(os.Args[2:])
|
||||||
return
|
return
|
||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v", "-version":
|
||||||
printVersion()
|
printVersion()
|
||||||
return
|
return
|
||||||
case "migrate-secrets":
|
case "migrate-secrets":
|
||||||
runMigrateSecrets(os.Args[2:])
|
runMigrateSecrets(os.Args[2:])
|
||||||
return
|
return
|
||||||
|
case "help", "--help", "-h":
|
||||||
|
printUsage()
|
||||||
|
return
|
||||||
|
case "serve":
|
||||||
|
runServe(os.Args[2:])
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runServe(os.Args[1:])
|
runServe(os.Args[1:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `Usage: uptop <command> [flags]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
serve Start the server (default if no command given)
|
||||||
|
apply Apply monitors from a YAML file
|
||||||
|
export Export monitors to YAML
|
||||||
|
migrate-secrets Re-encrypt alert credentials with current key
|
||||||
|
version Print version and exit
|
||||||
|
|
||||||
|
Run 'uptop serve --help' for server flags.
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
out := "uptop " + version
|
out := "uptop " + version
|
||||||
var meta []string
|
var meta []string
|
||||||
@@ -186,7 +206,7 @@ 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("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.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
|
||||||
|
|
||||||
@@ -219,7 +239,7 @@ 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("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.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
|
||||||
|
|
||||||
@@ -239,7 +259,7 @@ func runExport(args []string) {
|
|||||||
|
|
||||||
func runMigrateSecrets(args []string) {
|
func runMigrateSecrets(args []string) {
|
||||||
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
@@ -331,7 +351,7 @@ func runServe(args []string) {
|
|||||||
|
|
||||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
port := fs.Int("port", cfg.Port, "SSH Port")
|
port := fs.Int("port", cfg.Port, "SSH Port")
|
||||||
flagDBType := fs.String("db-type", cfg.DBType, "Database type")
|
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)")
|
||||||
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
||||||
demo := fs.Bool("demo", false, "Seed demo data")
|
demo := fs.Bool("demo", false, "Seed demo data")
|
||||||
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ services:
|
|||||||
leader:
|
leader:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-leader
|
container_name: uptop-leader
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
ports:
|
ports:
|
||||||
- "23234:23234" # SSH
|
- "23234:23234" # SSH
|
||||||
- "8080:8080" # HTTP
|
- "8080:8080" # HTTP
|
||||||
@@ -40,6 +42,8 @@ services:
|
|||||||
follower:
|
follower:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
container_name: uptop-follower
|
container_name: uptop-follower
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
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)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
- UPTOP_DB_TYPE=postgres
|
- UPTOP_DB_TYPE=postgres
|
||||||
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
||||||
|
|
||||||
# --- Web Server Configuration (Phase 4) ---
|
# --- Web Server Configuration ---
|
||||||
- UPTOP_HTTP_PORT=8080
|
- UPTOP_HTTP_PORT=8080
|
||||||
- UPTOP_STATUS_ENABLED=true
|
- UPTOP_STATUS_ENABLED=true
|
||||||
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
leader:
|
leader:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=leader
|
- UPTOP_CLUSTER_MODE=leader
|
||||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||||
@@ -12,6 +14,8 @@ services:
|
|||||||
|
|
||||||
probe-us-east:
|
probe-us-east:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=us-east-1
|
- UPTOP_NODE_ID=us-east-1
|
||||||
@@ -24,6 +28,8 @@ services:
|
|||||||
|
|
||||||
probe-eu-west:
|
probe-eu-west:
|
||||||
image: lerkolabs/uptop:latest
|
image: lerkolabs/uptop:latest
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
- UPTOP_CLUSTER_MODE=probe
|
||||||
- UPTOP_NODE_ID=eu-west-1
|
- UPTOP_NODE_ID=eu-west-1
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- ALL
|
- ALL
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+2
-4
@@ -47,13 +47,11 @@ Probes are lightweight, stateless nodes that run checks from different locations
|
|||||||
| Node | Variable | Value |
|
| Node | Variable | Value |
|
||||||
|------|----------|-------|
|
|------|----------|-------|
|
||||||
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
|
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
|
||||||
| Leader | `UPTOP_AGG_STRATEGY` | `any-down`, `majority-down`, or `all-down` |
|
|
||||||
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
|
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
|
||||||
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
|
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
|
||||||
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
|
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
|
||||||
| Probe | `UPTOP_NODE_REGION` | Region tag matching monitor assignments |
|
|
||||||
|
|
||||||
Optional: `UPTOP_NODE_NAME` for a human-readable label in the TUI.
|
Optional: `UPTOP_AGG_STRATEGY` (default `any-down`), `UPTOP_NODE_REGION` (omit to match all monitors), `UPTOP_NODE_NAME` (human-readable label in the TUI).
|
||||||
|
|
||||||
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
|
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
|
||||||
|
|
||||||
@@ -80,6 +78,6 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
|
|||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated.
|
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints reject all requests (fail closed); only `/api/health` stays open.
|
||||||
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
|
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
|
||||||
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
||||||
|
|||||||
@@ -159,10 +159,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("OK"))
|
_, _ = w.Write([]byte("OK"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ func TestHealth_WrongSecret(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 401 {
|
if resp.StatusCode != 200 {
|
||||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -17,6 +17,17 @@ func parseHex(hex string) (r, g, b uint8) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trueColorHex(c lipgloss.TerminalColor) string {
|
||||||
|
switch v := c.(type) {
|
||||||
|
case lipgloss.CompleteColor:
|
||||||
|
return v.TrueColor
|
||||||
|
case lipgloss.Color:
|
||||||
|
return string(v)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func dimColor(hex string, brightness float64) lipgloss.Color {
|
func dimColor(hex string, brightness float64) lipgloss.Color {
|
||||||
r, g, b := parseHex(hex)
|
r, g, b := parseHex(hex)
|
||||||
f := 0.3 + brightness*0.7
|
f := 0.3 + brightness*0.7
|
||||||
@@ -27,35 +38,36 @@ func dimColor(hex string, brightness float64) lipgloss.Color {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
|
func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||||
if bg != "" {
|
if bg != nil {
|
||||||
return s.Background(bg)
|
return s.Background(bg)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
|
func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||||
var hex string
|
var base lipgloss.TerminalColor
|
||||||
var t float64
|
var t float64
|
||||||
switch {
|
switch {
|
||||||
case ms < 200:
|
case ms < 200:
|
||||||
hex = m.st.sparkSuccess
|
base = m.st.sparkSuccess
|
||||||
t = float64(ms) / 200
|
t = float64(ms) / 200
|
||||||
case ms < 500:
|
case ms < 500:
|
||||||
hex = m.st.sparkWarning
|
base = m.st.sparkWarning
|
||||||
t = float64(ms-200) / 300
|
t = float64(ms-200) / 300
|
||||||
default:
|
default:
|
||||||
hex = m.st.sparkDanger
|
base = m.st.sparkDanger
|
||||||
t = float64(ms-500) / 1500
|
t = float64(ms-500) / 1500
|
||||||
if t > 1 {
|
if t > 1 {
|
||||||
t = 1
|
t = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hex := trueColorHex(base)
|
||||||
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
||||||
return withBg(s, bg)
|
return withBg(s, bg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
|
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||||
if len(latencies) == 0 {
|
if len(latencies) == 0 {
|
||||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
@@ -103,7 +115,7 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
|
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
@@ -143,7 +155,7 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
|
|||||||
return offset + (x - padding)
|
return offset + (x - padding)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
|
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
||||||
allSites := m.engine.GetAllSites()
|
allSites := m.engine.GetAllSites()
|
||||||
var childStatuses [][]bool
|
var childStatuses [][]bool
|
||||||
for _, s := range allSites {
|
for _, s := range allSites {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLatencySparkline_Empty(t *testing.T) {
|
func TestLatencySparkline_Empty(t *testing.T) {
|
||||||
got := styledModel.latencySparkline(nil, nil, 10, "")
|
got := styledModel.latencySparkline(nil, nil, 10, nil)
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty sparkline should be dots, got %q", got)
|
t.Errorf("empty sparkline should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -17,7 +19,7 @@ func TestLatencySparkline_Empty(t *testing.T) {
|
|||||||
func TestLatencySparkline_SingleValue(t *testing.T) {
|
func TestLatencySparkline_SingleValue(t *testing.T) {
|
||||||
latencies := []time.Duration{100 * time.Millisecond}
|
latencies := []time.Duration{100 * time.Millisecond}
|
||||||
statuses := []bool{true}
|
statuses := []bool{true}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|||||||
latencies[i] = time.Duration(i*50) * time.Millisecond
|
latencies[i] = time.Duration(i*50) * time.Millisecond
|
||||||
statuses[i] = true
|
statuses[i] = true
|
||||||
}
|
}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("sparkline should not be empty")
|
t.Error("sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
|||||||
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
||||||
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
||||||
statuses := []bool{true, true, true}
|
statuses := []bool{true, true, true}
|
||||||
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
|
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil))
|
||||||
runes := []rune(out)
|
runes := []rune(out)
|
||||||
if len(runes) < 3 {
|
if len(runes) < 3 {
|
||||||
t.Fatalf("expected 3 runes, got %d", len(runes))
|
t.Fatalf("expected 3 runes, got %d", len(runes))
|
||||||
@@ -57,14 +59,14 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
|||||||
|
|
||||||
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
||||||
st := newStyles(themeFlexokiDark)
|
st := newStyles(themeFlexokiDark)
|
||||||
st.sparkSuccess = "#00ff00"
|
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||||
st.sparkWarning = "#ffff00"
|
st.sparkWarning = lipgloss.Color("#ffff00")
|
||||||
st.sparkDanger = "#ff0000"
|
st.sparkDanger = lipgloss.Color("#ff0000")
|
||||||
m := Model{st: st}
|
m := Model{st: st}
|
||||||
|
|
||||||
green := m.latencyStyle(50, "")
|
green := m.latencyStyle(50, nil)
|
||||||
yellow := m.latencyStyle(300, "")
|
yellow := m.latencyStyle(300, nil)
|
||||||
red := m.latencyStyle(800, "")
|
red := m.latencyStyle(800, nil)
|
||||||
|
|
||||||
gfg := green.GetForeground()
|
gfg := green.GetForeground()
|
||||||
yfg := yellow.GetForeground()
|
yfg := yellow.GetForeground()
|
||||||
@@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
|||||||
|
|
||||||
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
||||||
st := newStyles(themeFlexokiDark)
|
st := newStyles(themeFlexokiDark)
|
||||||
st.sparkSuccess = "#00ff00"
|
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||||
m := Model{st: st}
|
m := Model{st: st}
|
||||||
|
|
||||||
dim := m.latencyStyle(10, "")
|
dim := m.latencyStyle(10, nil)
|
||||||
bright := m.latencyStyle(190, "")
|
bright := m.latencyStyle(190, nil)
|
||||||
|
|
||||||
if dim.GetForeground() == bright.GetForeground() {
|
if dim.GetForeground() == bright.GetForeground() {
|
||||||
t.Error("10ms and 190ms should have different brightness within green band")
|
t.Error("10ms and 190ms should have different brightness within green band")
|
||||||
@@ -91,7 +93,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
|||||||
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
||||||
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
||||||
statuses := []bool{true, true, true}
|
statuses := []bool{true, true, true}
|
||||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||||
count := utf8.RuneCountInString(stripANSI(got))
|
count := utf8.RuneCountInString(stripANSI(got))
|
||||||
if count != 5 {
|
if count != 5 {
|
||||||
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
||||||
@@ -116,7 +118,7 @@ func stripANSI(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||||
got := styledModel.heartbeatSparkline(nil, 10, "")
|
got := styledModel.heartbeatSparkline(nil, 10, nil)
|
||||||
if !strings.Contains(got, "··········") {
|
if !strings.Contains(got, "··········") {
|
||||||
t.Errorf("empty heartbeat should be dots, got %q", got)
|
t.Errorf("empty heartbeat should be dots, got %q", got)
|
||||||
}
|
}
|
||||||
@@ -124,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||||
statuses := []bool{true, false, true, true, false}
|
statuses := []bool{true, false, true, true, false}
|
||||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||||
if len(got) == 0 {
|
if len(got) == 0 {
|
||||||
t.Error("heartbeat sparkline should not be empty")
|
t.Error("heartbeat sparkline should not be empty")
|
||||||
}
|
}
|
||||||
@@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
|||||||
|
|
||||||
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
||||||
statuses := []bool{true, true}
|
statuses := []bool{true, true}
|
||||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||||
if !strings.Contains(got, "···") {
|
if !strings.Contains(got, "···") {
|
||||||
t.Errorf("should have dot padding for width > data, got %q", got)
|
t.Errorf("should have dot padding for width > data, got %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
site := m.sites[i]
|
site := m.sites[i]
|
||||||
rowIdx := i - start
|
rowIdx := i - start
|
||||||
var rowBg lipgloss.Color
|
var rowBg lipgloss.TerminalColor
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
rowBg = m.theme.SelectedBg
|
rowBg = m.theme.SelectedBg
|
||||||
} else if rowIdx%2 == 1 {
|
} else if rowIdx%2 == 1 {
|
||||||
|
|||||||
+110
-102
@@ -5,35 +5,43 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func cc(hex, ansi string) lipgloss.CompleteColor {
|
||||||
|
return lipgloss.CompleteColor{
|
||||||
|
TrueColor: hex,
|
||||||
|
ANSI256: hex,
|
||||||
|
ANSI: ansi,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
Name string
|
Name string
|
||||||
|
|
||||||
// Base layers
|
// Base layers
|
||||||
Bg lipgloss.Color
|
Bg lipgloss.TerminalColor
|
||||||
Surface lipgloss.Color
|
Surface lipgloss.TerminalColor
|
||||||
Panel lipgloss.Color
|
Panel lipgloss.TerminalColor
|
||||||
Border lipgloss.Color
|
Border lipgloss.TerminalColor
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
Fg lipgloss.Color
|
Fg lipgloss.TerminalColor
|
||||||
Muted lipgloss.Color
|
Muted lipgloss.TerminalColor
|
||||||
Subtle lipgloss.Color
|
Subtle lipgloss.TerminalColor
|
||||||
|
|
||||||
// Semantic
|
// Semantic
|
||||||
Success lipgloss.Color
|
Success lipgloss.TerminalColor
|
||||||
Warning lipgloss.Color
|
Warning lipgloss.TerminalColor
|
||||||
Stale lipgloss.Color
|
Stale lipgloss.TerminalColor
|
||||||
Danger lipgloss.Color
|
Danger lipgloss.TerminalColor
|
||||||
Info lipgloss.Color
|
Info lipgloss.TerminalColor
|
||||||
Accent lipgloss.Color
|
Accent lipgloss.TerminalColor
|
||||||
Purple lipgloss.Color
|
Purple lipgloss.TerminalColor
|
||||||
|
|
||||||
// Table
|
// Table
|
||||||
ZebraBg lipgloss.Color
|
ZebraBg lipgloss.TerminalColor
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
SelectedFg lipgloss.Color
|
SelectedFg lipgloss.TerminalColor
|
||||||
SelectedBg lipgloss.Color
|
SelectedBg lipgloss.TerminalColor
|
||||||
}
|
}
|
||||||
|
|
||||||
var themes = []Theme{
|
var themes = []Theme{
|
||||||
@@ -46,107 +54,107 @@ var themes = []Theme{
|
|||||||
|
|
||||||
var themeFlexokiDark = Theme{
|
var themeFlexokiDark = Theme{
|
||||||
Name: "Flexoki Dark",
|
Name: "Flexoki Dark",
|
||||||
Bg: "#1C1B1A",
|
Bg: cc("#1C1B1A", ""),
|
||||||
Surface: "#282726",
|
Surface: cc("#282726", ""),
|
||||||
Panel: "#343331",
|
Panel: cc("#343331", ""),
|
||||||
Border: "#575653",
|
Border: cc("#575653", "8"),
|
||||||
Fg: "#CECDC3",
|
Fg: cc("#CECDC3", "15"),
|
||||||
Muted: "#878580",
|
Muted: cc("#878580", "7"),
|
||||||
Subtle: "#6F6E69",
|
Subtle: cc("#6F6E69", "7"),
|
||||||
Success: "#879A39",
|
Success: cc("#879A39", "10"),
|
||||||
Warning: "#D0A215",
|
Warning: cc("#D0A215", "11"),
|
||||||
Stale: "#DA702C",
|
Stale: cc("#DA702C", "3"),
|
||||||
Danger: "#D14D41",
|
Danger: cc("#D14D41", "9"),
|
||||||
Info: "#4385BE",
|
Info: cc("#4385BE", "12"),
|
||||||
Accent: "#3AA99F",
|
Accent: cc("#3AA99F", "14"),
|
||||||
Purple: "#8B7EC8",
|
Purple: cc("#8B7EC8", "13"),
|
||||||
ZebraBg: "#222120",
|
ZebraBg: cc("#222120", ""),
|
||||||
SelectedFg: "#FFFCF0",
|
SelectedFg: cc("#FFFCF0", "15"),
|
||||||
SelectedBg: "#403E3C",
|
SelectedBg: cc("#403E3C", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeTokyoNight = Theme{
|
var themeTokyoNight = Theme{
|
||||||
Name: "Tokyo Night",
|
Name: "Tokyo Night",
|
||||||
Bg: "#1a1b26",
|
Bg: cc("#1a1b26", ""),
|
||||||
Surface: "#24283b",
|
Surface: cc("#24283b", ""),
|
||||||
Panel: "#292e42",
|
Panel: cc("#292e42", ""),
|
||||||
Border: "#3b4261",
|
Border: cc("#3b4261", "8"),
|
||||||
Fg: "#c0caf5",
|
Fg: cc("#c0caf5", "15"),
|
||||||
Muted: "#a9b1d6",
|
Muted: cc("#a9b1d6", "7"),
|
||||||
Subtle: "#565f89",
|
Subtle: cc("#565f89", "7"),
|
||||||
Success: "#9ece6a",
|
Success: cc("#9ece6a", "10"),
|
||||||
Warning: "#e0af68",
|
Warning: cc("#e0af68", "11"),
|
||||||
Stale: "#ff9e64",
|
Stale: cc("#ff9e64", "3"),
|
||||||
Danger: "#f7768e",
|
Danger: cc("#f7768e", "9"),
|
||||||
Info: "#7aa2f7",
|
Info: cc("#7aa2f7", "12"),
|
||||||
Accent: "#7dcfff",
|
Accent: cc("#7dcfff", "14"),
|
||||||
Purple: "#bb9af7",
|
Purple: cc("#bb9af7", "13"),
|
||||||
ZebraBg: "#1c1d28",
|
ZebraBg: cc("#1c1d28", ""),
|
||||||
SelectedFg: "#c0caf5",
|
SelectedFg: cc("#c0caf5", "15"),
|
||||||
SelectedBg: "#292e42",
|
SelectedBg: cc("#292e42", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeGruvbox = Theme{
|
var themeGruvbox = Theme{
|
||||||
Name: "Gruvbox",
|
Name: "Gruvbox",
|
||||||
Bg: "#282828",
|
Bg: cc("#282828", ""),
|
||||||
Surface: "#3c3836",
|
Surface: cc("#3c3836", ""),
|
||||||
Panel: "#504945",
|
Panel: cc("#504945", ""),
|
||||||
Border: "#665c54",
|
Border: cc("#665c54", "8"),
|
||||||
Fg: "#ebdbb2",
|
Fg: cc("#ebdbb2", "15"),
|
||||||
Muted: "#bdae93",
|
Muted: cc("#bdae93", "7"),
|
||||||
Subtle: "#7c6f64",
|
Subtle: cc("#7c6f64", "7"),
|
||||||
Success: "#b8bb26",
|
Success: cc("#b8bb26", "10"),
|
||||||
Warning: "#fabd2f",
|
Warning: cc("#fabd2f", "11"),
|
||||||
Stale: "#fe8019",
|
Stale: cc("#fe8019", "3"),
|
||||||
Danger: "#fb4934",
|
Danger: cc("#fb4934", "9"),
|
||||||
Info: "#83a598",
|
Info: cc("#83a598", "12"),
|
||||||
Accent: "#8ec07c",
|
Accent: cc("#8ec07c", "14"),
|
||||||
Purple: "#d3869b",
|
Purple: cc("#d3869b", "13"),
|
||||||
ZebraBg: "#2a2a2a",
|
ZebraBg: cc("#2a2a2a", ""),
|
||||||
SelectedFg: "#fbf1c7",
|
SelectedFg: cc("#fbf1c7", "15"),
|
||||||
SelectedBg: "#504945",
|
SelectedBg: cc("#504945", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeCatppuccinMocha = Theme{
|
var themeCatppuccinMocha = Theme{
|
||||||
Name: "Catppuccin Mocha",
|
Name: "Catppuccin Mocha",
|
||||||
Bg: "#1e1e2e",
|
Bg: cc("#1e1e2e", ""),
|
||||||
Surface: "#313244",
|
Surface: cc("#313244", ""),
|
||||||
Panel: "#45475a",
|
Panel: cc("#45475a", ""),
|
||||||
Border: "#585b70",
|
Border: cc("#585b70", "8"),
|
||||||
Fg: "#cdd6f4",
|
Fg: cc("#cdd6f4", "15"),
|
||||||
Muted: "#a6adc8",
|
Muted: cc("#a6adc8", "7"),
|
||||||
Subtle: "#6c7086",
|
Subtle: cc("#6c7086", "7"),
|
||||||
Success: "#a6e3a1",
|
Success: cc("#a6e3a1", "10"),
|
||||||
Warning: "#f9e2af",
|
Warning: cc("#f9e2af", "11"),
|
||||||
Stale: "#fab387",
|
Stale: cc("#fab387", "3"),
|
||||||
Danger: "#f38ba8",
|
Danger: cc("#f38ba8", "9"),
|
||||||
Info: "#89b4fa",
|
Info: cc("#89b4fa", "12"),
|
||||||
Accent: "#94e2d5",
|
Accent: cc("#94e2d5", "14"),
|
||||||
Purple: "#cba6f7",
|
Purple: cc("#cba6f7", "13"),
|
||||||
ZebraBg: "#232334",
|
ZebraBg: cc("#232334", ""),
|
||||||
SelectedFg: "#cdd6f4",
|
SelectedFg: cc("#cdd6f4", "15"),
|
||||||
SelectedBg: "#45475a",
|
SelectedBg: cc("#45475a", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
var themeNord = Theme{
|
var themeNord = Theme{
|
||||||
Name: "Nord",
|
Name: "Nord",
|
||||||
Bg: "#2e3440",
|
Bg: cc("#2e3440", ""),
|
||||||
Surface: "#3b4252",
|
Surface: cc("#3b4252", ""),
|
||||||
Panel: "#434c5e",
|
Panel: cc("#434c5e", ""),
|
||||||
Border: "#4c566a",
|
Border: cc("#4c566a", "8"),
|
||||||
Fg: "#d8dee9",
|
Fg: cc("#d8dee9", "15"),
|
||||||
Muted: "#d8dee9",
|
Muted: cc("#d8dee9", "7"),
|
||||||
Subtle: "#4c566a",
|
Subtle: cc("#4c566a", "7"),
|
||||||
Success: "#a3be8c",
|
Success: cc("#a3be8c", "10"),
|
||||||
Warning: "#ebcb8b",
|
Warning: cc("#ebcb8b", "11"),
|
||||||
Stale: "#d08770",
|
Stale: cc("#d08770", "3"),
|
||||||
Danger: "#bf616a",
|
Danger: cc("#bf616a", "9"),
|
||||||
Info: "#81a1c1",
|
Info: cc("#81a1c1", "12"),
|
||||||
Accent: "#88c0d0",
|
Accent: cc("#88c0d0", "14"),
|
||||||
Purple: "#b48ead",
|
Purple: cc("#b48ead", "13"),
|
||||||
ZebraBg: "#323845",
|
ZebraBg: cc("#323845", ""),
|
||||||
SelectedFg: "#eceff4",
|
SelectedFg: cc("#eceff4", "15"),
|
||||||
SelectedBg: "#434c5e",
|
SelectedBg: cc("#434c5e", "4"),
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t Theme) HuhTheme() *huh.Theme {
|
func (t Theme) HuhTheme() *huh.Theme {
|
||||||
|
|||||||
+12
-12
@@ -30,9 +30,9 @@ type styles struct {
|
|||||||
activeTab lipgloss.Style
|
activeTab lipgloss.Style
|
||||||
inactiveTab lipgloss.Style
|
inactiveTab lipgloss.Style
|
||||||
|
|
||||||
sparkSuccess string
|
sparkSuccess lipgloss.TerminalColor
|
||||||
sparkWarning string
|
sparkWarning lipgloss.TerminalColor
|
||||||
sparkDanger string
|
sparkDanger lipgloss.TerminalColor
|
||||||
|
|
||||||
tableHeaderStyle lipgloss.Style
|
tableHeaderStyle lipgloss.Style
|
||||||
tableCellStyle lipgloss.Style
|
tableCellStyle lipgloss.Style
|
||||||
@@ -46,23 +46,23 @@ type styles struct {
|
|||||||
|
|
||||||
func newStyles(t Theme) *styles {
|
func newStyles(t Theme) *styles {
|
||||||
return &styles{
|
return &styles{
|
||||||
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle),
|
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
|
||||||
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
||||||
warnStyle: lipgloss.NewStyle().Foreground(t.Warning),
|
warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
|
||||||
staleStyle: lipgloss.NewStyle().Foreground(t.Stale),
|
staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
|
||||||
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger),
|
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
|
||||||
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
||||||
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
||||||
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted),
|
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted).Faint(true),
|
||||||
|
|
||||||
sparkSuccess: string(t.Success),
|
sparkSuccess: t.Success,
|
||||||
sparkWarning: string(t.Warning),
|
sparkWarning: t.Warning,
|
||||||
sparkDanger: string(t.Danger),
|
sparkDanger: t.Danger,
|
||||||
|
|
||||||
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
|
tableHeaderStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1),
|
||||||
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
|
tableCellStyle: lipgloss.NewStyle().Padding(0, 1),
|
||||||
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
|
tableSelectedStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg),
|
||||||
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border),
|
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border).Faint(true),
|
||||||
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
||||||
|
|
||||||
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, "")))
|
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, nil)))
|
||||||
if len(hist.Statuses) > 0 {
|
if len(hist.Statuses) > 0 {
|
||||||
up := 0
|
up := 0
|
||||||
for _, s := range hist.Statuses {
|
for _, s := range hist.Statuses {
|
||||||
@@ -228,7 +228,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up, len(hist.Statuses))
|
up, len(hist.Statuses))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, "")))
|
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, nil)))
|
||||||
var minL, maxL, total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
count := 0
|
count := 0
|
||||||
for i, l := range hist.Latencies {
|
for i, l := range hist.Latencies {
|
||||||
|
|||||||
Reference in New Issue
Block a user