6 Commits

Author SHA1 Message Date
lerko dbd519c121 fix: 4 additional release-consistency findings
CI / test (pull_request) Successful in 1m46s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Successful in 51s
- Disable healthcheck on probe compose services (no HTTP server)
- Remove stale "(Phase 4)" comment from dev compose
- Add data/ to .gitignore (compose volume creates deploy/data)
- Clarify -db-type flag help text (sqlite or postgres)
2026-06-19 20:37:42 -04:00
lerko b32145fb58 fix: resolve 13 release-consistency findings
Documentation:
- Fix CI badge href to /actions (was 404 on Gitea)
- Add UPTOP_METRICS_PUBLIC + UPTOP_MAINT_RETENTION to README env table
- Link maintenance retention to env var name in data retention section
- Note metrics auth requirement in features list
- Fix clustering.md: fail-closed wording, mark AGG_STRATEGY/NODE_REGION optional
- Fix .env.example: wording (no .env loader), add TRUSTED_PROXIES + MAINT_RETENTION
- Add CLI help/usage with subcommand listing, accept serve/help/-h/-version

Docker/deploy:
- Add EXPOSE 8080 to Dockerfile
- Remove dead LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND env
- Exempt /api/health from cluster auth (fixes Docker HEALTHCHECK 401)
- Add sysctls for unprivileged ping to all compose files

Cosmetic:
- Fix bug_report.yaml: SemVer placeholder, remove nonexistent serve subcommand
2026-06-19 20:09:03 -04:00
lerko 47d3b0e68f fix(tui): bump Subtle ANSI fallback from "8" to "7"
CI / test (pull_request) Successful in 1m49s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
Bright black ("8") plus Faint made PENDING status and dividers nearly
invisible in 16-color terminals. White ("7") with Faint renders as a
readable dim gray while still sitting below Muted in the hierarchy.
2026-06-19 17:27:54 -04:00
lerko 8fd13fefbf feat(tui): add monochrome emphasis attributes for SSH readability
Apply Bold/Faint attributes to semantic styles following htop's
monochrome design principle. Creates 4-tier visual hierarchy that
works even when colors collapse: Bold (danger/warn), Normal (success/
default), Faint (subtle/stale/borders/inactive tabs). Complements
the ANSI-16 color fallbacks without affecting TrueColor appearance.
2026-06-19 17:27:54 -04:00
lerko 974c4b61ea fix(tui): add ANSI-16 color fallbacks for SSH terminals
Theme colors now use lipgloss.CompleteColor with hand-picked ANSI-16
values instead of raw hex. Prevents algorithmic degradation from
collapsing dark backgrounds into indistinguishable ANSI colors over
SSH. Backgrounds fall through to terminal default in 16-color mode;
semantic colors map to distinct ANSI indices (green/yellow/red/blue/
cyan/magenta). TrueColor rendering is unchanged.
2026-06-19 17:27:54 -04:00
lerko d50a5159d4 fix(release): pin GoReleaser to triggering tag
CI / test (pull_request) Successful in 1m43s
CI / lint (pull_request) Successful in 1m22s
CI / vulncheck (pull_request) Successful in 56s
GORELEASER_CURRENT_TAG prevents GoReleaser from resolving the
wrong tag via git-describe when multiple tags point to the same
commit (e.g. v0.1.0 + v0.1.0-rc.5 on adf8fed).
2026-06-17 17:26:16 -04:00
20 changed files with 222 additions and 169 deletions
+3 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -25,4 +25,5 @@ authorized_keys
tmp tmp
*.local.json *.local.json
*.local.md *.local.md
data/
.env .env
+1 -2
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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")
+4
View 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)
+1 -1
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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.
-4
View File
@@ -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"))
} }
+2 -2
View File
@@ -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
View File
@@ -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 {
+19 -17
View File
@@ -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)
} }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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),
+2 -2
View File
@@ -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 {