diff --git a/README.md b/README.md index e0cc71e..8c9846d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,26 @@ # uptop-vhs -VHS tape files and tooling for generating uptop TUI screenshots \ No newline at end of file +VHS tape files and tooling for generating [uptop](https://gitea.lerkolabs.com/lerko/uptop) TUI screenshots. + +## Contents + +- `demo.tape` — VHS tape that drives the TUI through each tab +- `seed.yaml` — sample monitor/site definitions for realistic demo data +- `setup.sh` — bootstrap script: builds uptop, seeds DB, runs backfill +- `backfill/` — Go tool that generates realistic historical check data +- `crop/` — Go tool that crops VHS output into per-tab screenshots + +## Usage + +```bash +# 1. Set up and run the demo +./setup.sh + +# 2. Record with VHS +vhs demo.tape + +# 3. Crop into individual screenshots +go run ./crop/ +``` + +Screenshots land in `screenshots/` — copy them to the uptop repo's `screenshots/` directory. diff --git a/backfill/main.go b/backfill/main.go new file mode 100644 index 0000000..c839ccb --- /dev/null +++ b/backfill/main.go @@ -0,0 +1,368 @@ +package main + +import ( + "database/sql" + "fmt" + "math/rand/v2" + "os" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: backfill ") + os.Exit(1) + } + db, err := sql.Open("sqlite3", os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "open: %v\n", err) + os.Exit(1) + } + defer db.Close() + + ids, err := loadSiteIDs(db) + if err != nil { + fmt.Fprintf(os.Stderr, "load site IDs: %v\n", err) + os.Exit(1) + } + + rng := rand.New(rand.NewPCG(42, 0)) //nolint:gosec // deterministic seed for reproducible demo data + now := time.Now().UTC() + + if err := backfillHistory(db, rng, now, ids); err != nil { + fmt.Fprintf(os.Stderr, "history: %v\n", err) + os.Exit(1) + } + if err := backfillStateChanges(db, now, ids); err != nil { + fmt.Fprintf(os.Stderr, "state changes: %v\n", err) + os.Exit(1) + } + if err := backfillLogs(db, now); err != nil { + fmt.Fprintf(os.Stderr, "logs: %v\n", err) + os.Exit(1) + } + if err := backfillNodes(db, now); err != nil { + fmt.Fprintf(os.Stderr, "nodes: %v\n", err) + os.Exit(1) + } + if err := backfillMaintenance(db, now, ids); err != nil { + fmt.Fprintf(os.Stderr, "maintenance: %v\n", err) + os.Exit(1) + } + alertIDs, err := loadAlertIDs(db) + if err != nil { + fmt.Fprintf(os.Stderr, "load alert IDs: %v\n", err) + os.Exit(1) + } + if err := backfillAlertHealth(db, now, alertIDs); err != nil { + fmt.Fprintf(os.Stderr, "alert health: %v\n", err) + os.Exit(1) + } + + var count int + _ = db.QueryRow("SELECT COUNT(*) FROM check_history").Scan(&count) + fmt.Printf("Backfill complete: %d check records\n", count) + + var token string + if err := db.QueryRow("SELECT token FROM sites WHERE name='Nightly Backup'").Scan(&token); err == nil { + fmt.Printf("PUSH_TOKEN=%s\n", token) + } +} + +func loadSiteIDs(db *sql.DB) (map[string]int, error) { + rows, err := db.Query("SELECT id, name FROM sites") + if err != nil { + return nil, err + } + return scanNameIDs(rows) +} + +func loadAlertIDs(db *sql.DB) (map[string]int, error) { + rows, err := db.Query("SELECT id, name FROM alerts") + if err != nil { + return nil, err + } + return scanNameIDs(rows) +} + +func scanNameIDs(rows *sql.Rows) (map[string]int, error) { + defer rows.Close() + ids := make(map[string]int) + for rows.Next() { + var id int + var name string + if err := rows.Scan(&id, &name); err != nil { + return nil, err + } + ids[name] = id + } + return ids, rows.Err() +} + +// backfillAlertHealth seeds realistic send health so the Alerts tab shows recent, +// healthy "last sent" times and green health dots instead of "never" across the board. +func backfillAlertHealth(db *sql.DB, now time.Time, alertIDs map[string]int) error { + type health struct { + name string + sentAgo time.Duration + ok bool + sends int + fails int + } + rows := []health{ + {"Discord Homelab", 4 * time.Minute, true, 37, 0}, + {"Slack Ops", 9 * time.Minute, true, 21, 1}, + {"Ntfy Alerts", 1 * time.Hour, true, 12, 0}, + {"Email Oncall", 3 * time.Hour, true, 5, 0}, + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare("INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, r := range rows { + id, ok := alertIDs[r.name] + if !ok { + continue + } + sentAt := now.Add(-r.sentAgo).Format("2006-01-02 15:04:05") + if _, err := stmt.Exec(id, sentAt, r.ok, "", r.sends, r.fails); err != nil { + return err + } + } + return tx.Commit() +} + +type monitorProfile struct { + name string + minMs int + maxMs int + downFrom int // first DOWN check index (-1 = always up) + downTo int // exclusive end of the DOWN window; use 60 (total) for a still-down monitor +} + +func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]int) error { + // Latency ranges reflect monitoring public services over the internet, so the + // detail histogram brackets the live latency the engine measures at capture time. + // 60 checks * 24m spacing = a 24h window; dip indices place outages within it. + profiles := []monitorProfile{ + {"Nextcloud", 200, 600, 47, 48}, // brief blip ~5h ago, recovered + {"Jellyfin", 40, 180, 15, 16}, // brief blip ~18h ago, recovered + {"Home Assistant", 30, 120, -1, 0}, // + {"Gitea", 50, 200, -1, 0}, // + {"Traefik Dashboard", 60, 200, -1, 0}, // + {"Vaultwarden", 80, 250, -1, 0}, // + {"Personal Blog", 40, 160, -1, 0}, // + {"Immich", 60, 300, 30, 31}, // brief blip ~12h ago; periodic spikes below + {"Auth Portal", 30, 90, 40, 60}, // DOWN ~8h ago, still down + {"Edge Router", 5, 20, -1, 0}, // ping + {"Postgres", 1, 6, -1, 0}, // port + {"DNS Primary", 8, 30, -1, 0}, // dns + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare("INSERT INTO check_history (site_id, latency_ns, is_up, checked_at) VALUES (?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + const total = 60 + for _, p := range profiles { + siteID, ok := ids[p.name] + if !ok { + continue + } + for i := 0; i < total; i++ { + minutesAgo := (total - i) * 24 + checkedAt := now.Add(-time.Duration(minutesAgo) * time.Minute) + + var latencyNs int64 + isUp := true + + if p.downFrom >= 0 && i >= p.downFrom && i < p.downTo { + latencyNs = 0 + isUp = false + } else { + ms := p.minMs + rng.IntN(p.maxMs-p.minMs) + if p.name == "Immich" && i%17 == 0 { + ms = 250 + rng.IntN(100) + } + latencyNs = int64(ms) * 1_000_000 + } + + if _, err := stmt.Exec(siteID, latencyNs, isUp, checkedAt.Format("2006-01-02 15:04:05")); err != nil { + return err + } + } + } + return tx.Commit() +} + +func backfillStateChanges(db *sql.DB, now time.Time, ids map[string]int) error { + type sc struct { + name string + from string + to string + reason string + at time.Time + } + // Timed to line up with the history dips (Nextcloud ~5h, Immich ~12h, Jellyfin ~18h) + // and the still-down Auth Portal (~8h), so detail panels read coherently. + changes := []sc{ + {"Nextcloud", "UP", "DOWN", "read timeout", now.Add(-5 * time.Hour).Add(-8 * time.Minute)}, + {"Nextcloud", "DOWN", "UP", "", now.Add(-5 * time.Hour)}, + {"Auth Portal", "UP", "DOWN", "no such host", now.Add(-8 * time.Hour)}, + {"Immich", "UP", "DOWN", "502 Bad Gateway", now.Add(-12 * time.Hour).Add(-8 * time.Minute)}, + {"Immich", "DOWN", "UP", "", now.Add(-12 * time.Hour)}, + {"Jellyfin", "UP", "DOWN", "connection reset", now.Add(-18 * time.Hour).Add(-5 * time.Minute)}, + {"Jellyfin", "DOWN", "UP", "", now.Add(-18 * time.Hour)}, + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare("INSERT INTO state_changes (site_id, from_status, to_status, error_reason, changed_at) VALUES (?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, c := range changes { + siteID, ok := ids[c.name] + if !ok { + continue + } + if _, err := stmt.Exec(siteID, c.from, c.to, c.reason, c.at.Format("2006-01-02 15:04:05")); err != nil { + return err + } + } + return tx.Commit() +} + +func backfillLogs(db *sql.DB, now time.Time) error { + type logEntry struct { + text string + at time.Time + } + ago := func(h, m, s int) time.Time { + return now.Add(-(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second)) + } + // Ordered newest-first. The bracket time is derived from `at` (not hardcoded), so the + // Logs view — which renders the leading [HH:MM] — reads chronologically. Outage times + // line up with the state changes and history dips above. + logs := []logEntry{ + {"Monitor 'Nextcloud' recovered (was down 8m)", ago(5, 0, 0)}, + {"Monitor 'Nextcloud' confirmed DOWN: read timeout", ago(5, 8, 0)}, + {"Monitor 'Nextcloud' failed check 2/2", ago(5, 8, 30)}, + {"Monitor 'Nextcloud' failed check 1/2", ago(5, 9, 0)}, + {"Monitor 'Auth Portal' confirmed DOWN: no such host", ago(8, 0, 0)}, + {"Monitor 'Auth Portal' failed check 2/2", ago(8, 0, 30)}, + {"Monitor 'Auth Portal' failed check 1/2", ago(8, 1, 0)}, + {"Monitor 'Immich' recovered (was down 8m)", ago(12, 0, 0)}, + {"Monitor 'Immich' confirmed DOWN: 502 Bad Gateway", ago(12, 8, 0)}, + {"Monitor 'Immich' failed check 3/3", ago(12, 8, 30)}, + {"Monitor 'Immich' failed check 2/3", ago(12, 9, 0)}, + {"Monitor 'Immich' failed check 1/3", ago(12, 9, 30)}, + {"Monitor 'Jellyfin' recovered (was down 5m)", ago(18, 0, 0)}, + {"Monitor 'Jellyfin' confirmed DOWN: connection reset", ago(18, 5, 0)}, + {"Monitor 'Jellyfin' failed check 2/2", ago(18, 5, 30)}, + {"Monitor 'Jellyfin' failed check 1/2", ago(18, 6, 0)}, + {"SSL warning: certificate for 'Personal Blog' expires in 9 days", ago(20, 0, 0)}, + {"Engine RESUMED (Active)", ago(22, 0, 0)}, + {"Loaded check history from database", ago(22, 0, 5)}, + } + + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare("INSERT INTO logs (message, created_at) VALUES (?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + for _, l := range logs { + // Bracket in local time to match the engine's live AddLog timestamps; + // created_at stays UTC to match the store's CURRENT_TIMESTAMP ordering. + msg := "[" + l.at.Local().Format("15:04") + "] " + l.text + if _, err := stmt.Exec(msg, l.at.Format("2006-01-02 15:04:05")); err != nil { + return err + } + } + return tx.Commit() +} + +func backfillNodes(db *sql.DB, now time.Time) error { + // Multiple regions to show distributed probes. All seen "now" so they read ONLINE + // for the whole capture window (kept under the 60s freshness threshold by the tape). + nodes := []struct{ id, name, region string }{ + {"node-use1", "leader", "us-east"}, + {"node-euw1", "probe-eu", "eu-west"}, + {"node-apse1", "probe-ap", "ap-southeast"}, + } + ts := now.Format("2006-01-02 15:04:05") + for _, n := range nodes { + if _, err := db.Exec( + "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, ?, ?)", + n.id, n.name, n.region, ts, "2026.05.1", + ); err != nil { + return err + } + } + return nil +} + +func backfillMaintenance(db *sql.DB, now time.Time, ids map[string]int) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + stmt, err := tx.Prepare("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + jellyfinID := ids["Jellyfin"] + past := now.Add(-3 * 24 * time.Hour) + if _, err := stmt.Exec(jellyfinID, "Jellyfin upgrade", "Upgrade to v10.10 + plugin updates", "maintenance", + past.Format("2006-01-02 15:04:05"), + past.Add(2*time.Hour).Format("2006-01-02 15:04:05"), + "admin"); err != nil { + return err + } + + future := now.Add(2 * 24 * time.Hour) + if _, err := stmt.Exec(0, "Network switch replacement", "Replacing core switch in rack 2", "maintenance", + future.Format("2006-01-02 15:04:05"), + future.Add(4*time.Hour).Format("2006-01-02 15:04:05"), + "admin"); err != nil { + return err + } + + return tx.Commit() +} diff --git a/crop/main.go b/crop/main.go new file mode 100644 index 0000000..42edad9 --- /dev/null +++ b/crop/main.go @@ -0,0 +1,123 @@ +// Command crop trims the uniform background border around each VHS screenshot so the +// content fills the frame instead of floating in a large empty terminal. Sparse views +// (alerts, detail, nodes) would otherwise sit in a sea of dead space. +// +// Usage: crop [dir] (dir defaults to vhs/screenshots) +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" +) + +// pad is the margin (px) left around the detected content. tol is the per-channel +// colour distance (summed) above which a pixel counts as content rather than background. +const ( + pad = 24 + tol = 28 +) + +func main() { + dir := "vhs/screenshots" + if len(os.Args) > 1 { + dir = os.Args[1] + } + paths, err := filepath.Glob(filepath.Join(dir, "*.png")) + if err != nil { + fmt.Fprintf(os.Stderr, "glob: %v\n", err) + os.Exit(1) + } + if len(paths) == 0 { + fmt.Fprintf(os.Stderr, "no PNGs in %s\n", dir) + os.Exit(1) + } + for _, p := range paths { + w, h, err := cropFile(p) + if err != nil { + fmt.Fprintf(os.Stderr, "crop %s: %v\n", p, err) + os.Exit(1) + } + fmt.Printf("cropped %s -> %dx%d\n", filepath.Base(p), w, h) + } +} + +func cropFile(path string) (int, int, error) { + f, err := os.Open(path) //nolint:gosec // dev tool: paths come from a trusted local glob + if err != nil { + return 0, 0, err + } + src, err := png.Decode(f) + _ = f.Close() + if err != nil { + return 0, 0, err + } + + b := src.Bounds() + // Background colour sampled from a corner — always inside VHS's blank padding. + bgR, bgG, bgB := rgb(src.At(b.Min.X+2, b.Min.Y+2)) + + minX, minY := b.Max.X, b.Max.Y + maxX, maxY := b.Min.X, b.Min.Y + found := false + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl := rgb(src.At(x, y)) + if abs(r-bgR)+abs(g-bgG)+abs(bl-bgB) > tol { + found = true + minX, minY = min(minX, x), min(minY, y) + maxX, maxY = max(maxX, x), max(maxY, y) + } + } + } + if !found { + return b.Dx(), b.Dy(), nil // blank frame — leave untouched + } + + minX = clamp(minX-pad, b.Min.X, b.Max.X) + minY = clamp(minY-pad, b.Min.Y, b.Max.Y) + maxX = clamp(maxX+pad+1, b.Min.X, b.Max.X) + maxY = clamp(maxY+pad+1, b.Min.Y, b.Max.Y) + + dst := image.NewRGBA(image.Rect(0, 0, maxX-minX, maxY-minY)) + for y := minY; y < maxY; y++ { + for x := minX; x < maxX; x++ { + dst.Set(x-minX, y-minY, src.At(x, y)) + } + } + + out, err := os.Create(path) //nolint:gosec // dev tool: paths come from a trusted local glob + if err != nil { + return 0, 0, err + } + defer out.Close() //nolint:errcheck // best-effort close on write path + if err := png.Encode(out, dst); err != nil { + return 0, 0, err + } + return dst.Bounds().Dx(), dst.Bounds().Dy(), nil +} + +func rgb(c color.Color) (int, int, int) { + r, g, b, _ := c.RGBA() + return int(r >> 8), int(g >> 8), int(b >> 8) +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000..25c72dd --- /dev/null +++ b/demo.tape @@ -0,0 +1,72 @@ +Set Shell "bash" +Set Width 1400 +Set Height 800 +Set FontSize 14 +Set Padding 20 +Set Framerate 15 +Set TypingSpeed 50ms + +# Seed demo data + start uptop (UPTOP_DEMO=1 → stable pulse dot for stills). +Hide +Type "bash vhs/setup.sh /tmp/uptop-vhs.db" +Enter +# Warm-up: push heartbeat lands (~10s) and initial checks settle. Kept short so every +# capture stays inside the 60s node-freshness window (consistent "3 probes" footer). +Sleep 18s +Show +Sleep 2s + +# 1. Sites — hero shot: mixed states, history sparklines, SSL, retries. +Screenshot vhs/screenshots/monitors.png +Sleep 1s + +# 2. Detail — drill into Nextcloud (6th row from the top). +Down +Sleep 150ms +Down +Sleep 150ms +Down +Sleep 150ms +Down +Sleep 150ms +Down +Sleep 300ms +Type "i" +Sleep 2s +Screenshot vhs/screenshots/detail.png +Sleep 500ms +Escape +Sleep 1s + +# 3. Alerts — channels with health dots + recent "last sent". +Tab +Sleep 1500ms +Screenshot vhs/screenshots/alerts.png +Sleep 500ms + +# 4. Logs — chronological, severity-coloured event stream. +Tab +Sleep 1500ms +Screenshot vhs/screenshots/logs.png +Sleep 500ms + +# 5. Nodes — distributed probes across regions. +Tab +Sleep 1500ms +Screenshot vhs/screenshots/nodes.png +Sleep 500ms + +# 6. Theme — cycle to the next theme, return to Sites for an alternate-palette hero. +Type "T" +Sleep 500ms +Tab +Sleep 200ms +Tab +Sleep 200ms +Tab +Sleep 1s +Screenshot vhs/screenshots/theme.png +Sleep 500ms + +Type "q" +Sleep 1s diff --git a/seed.yaml b/seed.yaml new file mode 100644 index 0000000..30b34eb --- /dev/null +++ b/seed.yaml @@ -0,0 +1,141 @@ +alerts: + - name: Discord Homelab + type: discord + settings: + url: https://discord.com/api/webhooks/1234567890/demo-token + + - name: Ntfy Alerts + type: webhook + settings: + url: https://ntfy.example.com/homelab-alerts + + - name: Email Oncall + type: email + settings: + host: smtp.example.com + port: "587" + user: alerts@example.com + pass: "••••••••" + from: alerts@example.com + to: oncall@example.com + + - name: Slack Ops + type: slack + settings: + url: https://hooks.slack.com/services/T00000/B00000/demo-token + +monitors: + # HTTP — homelab services + - name: Nextcloud + type: http + url: https://nextcloud.com + interval: 30 + alert: Discord Homelab + check_ssl: true + expiry_threshold: 14 + max_retries: 2 + + - name: Jellyfin + type: http + url: https://jellyfin.org + interval: 30 + alert: Discord Homelab + max_retries: 2 + + - name: Home Assistant + type: http + url: https://www.home-assistant.io + interval: 30 + alert: Discord Homelab + max_retries: 3 + + - name: Gitea + type: http + url: https://about.gitea.com + interval: 60 + alert: Discord Homelab + check_ssl: true + expiry_threshold: 14 + max_retries: 2 + + - name: Traefik Dashboard + type: http + url: https://traefik.io + interval: 60 + alert: Discord Homelab + max_retries: 1 + + - name: Vaultwarden + type: http + url: https://bitwarden.com + interval: 30 + alert: Discord Homelab + check_ssl: true + expiry_threshold: 14 + max_retries: 3 + + - name: Personal Blog + type: http + url: https://jvns.ca + interval: 120 + alert: Discord Homelab + check_ssl: true + expiry_threshold: 14 + max_retries: 2 + + - name: Immich + type: http + url: https://immich.app + interval: 60 + alert: Discord Homelab + check_ssl: true + expiry_threshold: 7 + max_retries: 3 + + # HTTP — deliberate failure (non-resolving homelab host → stays DOWN) + - name: Auth Portal + type: http + url: https://auth.home.arpa + interval: 30 + alert: Discord Homelab + max_retries: 2 + + # Push — cron jobs + - name: Nightly Backup + type: push + interval: 300 + alert: Discord Homelab + + - name: Cert Renewal + type: push + interval: 300 + alert: Discord Homelab + + # Infrastructure group + - name: Infrastructure + type: group + alert: Discord Homelab + monitors: + - name: Edge Router + type: ping + hostname: 8.8.8.8 + interval: 30 + alert: Discord Homelab + timeout: 5 + + - name: Postgres + type: port + hostname: localhost + port: 18099 + interval: 60 + alert: Discord Homelab + timeout: 5 + + - name: DNS Primary + type: dns + hostname: google.com + dns_server: 8.8.8.8 + dns_resolve_type: A + interval: 60 + alert: Discord Homelab + timeout: 5 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..6bf1364 --- /dev/null +++ b/setup.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# VHS screenshot setup: seed monitors, backfill history, start server. +set -e +DB="${1:?usage: setup.sh }" + +rm -f "$DB" "$DB-shm" "$DB-wal" + +echo "==> Seeding monitors and alerts..." +UPTOP_DB_DSN="$DB" ./uptop apply -f vhs/seed.yaml 2>&1 + +echo "==> Backfilling check history..." +# Build first so the backfill's `now` (node last_seen, heartbeat timing) isn't racing +# a cold compile — keeps the capture window deterministic. +go build -o /tmp/uptop-backfill ./vhs/backfill/ +BACKFILL_OUT=$(/tmp/uptop-backfill "$DB") +echo "$BACKFILL_OUT" + +PUSH_TOKEN=$(echo "$BACKFILL_OUT" | grep '^PUSH_TOKEN=' | cut -d= -f2) +if [ -n "$PUSH_TOKEN" ]; then + echo "==> Sending push heartbeat in 10s (background)..." + (sleep 10 && curl -s "http://localhost:18099/api/push" -H "Authorization: Bearer $PUSH_TOKEN" > /dev/null 2>&1) & +fi + +echo "==> Starting uptop server..." +exec env \ + UPTOP_DB_DSN="$DB" \ + UPTOP_PORT=23299 \ + UPTOP_HTTP_PORT=18099 \ + UPTOP_ALLOW_PRIVATE_TARGETS=true \ + UPTOP_DEMO=1 \ + ./uptop serve 2>/dev/null