Skip to content

Commit a337b89

Browse files
committed
Merge branch 'feature/dashboard-gear'
2 parents 6e76cc1 + 04ef8b7 commit a337b89

62 files changed

Lines changed: 9637 additions & 30 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@ TASKS.md is a **scratch pad only** — not a tracking system. The GitHub Project
159159
- Fenced blocks provide a copy button in the IDE
160160
- Never use inline code for commands the user should run
161161

162+
## UI Conventions
163+
164+
### Modal dialogs — never use native `confirm()`, `alert()`, or `prompt()`
165+
166+
The base layout (`internal/framework/templates/layouts/base.templ`) renders three globally-available themed dialog components — `@ConfirmDialog()`, `@PromptDialog()`, `@AlertDialog()` — with a Promise-returning JS API. Use these instead of the browser primitives so styling, dark-mode, escape-key handling, and a11y are consistent across the app.
167+
168+
```javascript
169+
// Confirmations (returns boolean)
170+
const confirmed = await showConfirmDialog({
171+
title: 'Delete board',
172+
message: 'Delete "Media"? All tiles on this board will be removed. This cannot be undone.',
173+
confirmText: 'Delete board',
174+
type: 'danger', // 'warning' (default) | 'danger' | 'info'
175+
});
176+
if (!confirmed) return;
177+
178+
// Text input (returns string|null)
179+
const name = await showPromptDialog({
180+
title: 'New board',
181+
message: 'What should this board be called?',
182+
placeholder: 'Media',
183+
defaultValue: '',
184+
});
185+
186+
// One-button alerts (returns void)
187+
await showAlertDialog({
188+
title: 'Failed to delete board',
189+
message: err.message,
190+
type: 'error', // 'error' (default) | 'success' | 'info'
191+
});
192+
```
193+
194+
**Rules of thumb:**
195+
196+
- `type: 'danger'` for destructive confirmations — gives a red button + warning icon.
197+
- For non-blocking failure feedback that doesn't need a button click, prefer `window.showToast(msg, level)` instead — the dialog API blocks until acknowledged.
198+
- All three return Promises; the calling event handler must be `async`.
199+
- Dialogs are already rendered into the DOM by `layouts.Base` — don't re-instantiate per-page.
200+
201+
Existing reference usages: [user-pages/admin-user-detail.js](static/js/user-pages/admin-user-detail.js), [user-pages/profile-management.js](static/js/user-pages/profile-management.js), [haproxy_config/editor.js](static/js/haproxy_config/editor.js).
202+
162203
## Key Constraints
163204

164205
### NEVER

docs/gears.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ Client gears run in the **gearbox** web application (the monitoring client) and
131131

132132
**Examples:**
133133

134+
- `home` - Box-agnostic app dashboard (start page with launcher tiles, widgets, bookmarks, search)
134135
- `dashboard` - Main monitoring overview page (the homepage)
135136
- `certificates` - SSL/TLS certificate monitoring page
136137
- `logs` - Log viewing and analysis page
@@ -139,6 +140,26 @@ Client gears run in the **gearbox** web application (the monitoring client) and
139140
- `services` - Service status and control page
140141
- `metrics` - System metrics and history page
141142

143+
### Gear Scope: Box vs System
144+
145+
Most gears are **box-scoped** — they monitor a specific server, and the `gears` table holds one row per `(server_id, name)`. A few gears are **system-scoped** — they apply to the whole install, with a single row keyed by the sentinel `server_id = '__system__'`.
146+
147+
Declare the scope on `Info`:
148+
149+
```go
150+
func (g *Gear) Info() gear.Info {
151+
return gear.Info{
152+
Name: "home",
153+
Scope: gear.ScopeSystem, // omit or set ScopeBox for the default per-box behaviour
154+
// ...
155+
}
156+
}
157+
```
158+
159+
System gears are seeded once at startup via `database.EnsureSystemGears()` (called from `cmd/server/main.go`). Box gears are seeded lazily per-server via `EnsureServerGears(boxID)` on first access, just like before. The two seeding lists are kept disjoint by construction (`DefaultGears` vs `DefaultSystemGears`).
160+
161+
The `home` gear is the first system-scoped gear and a useful reference. Its config lives in `database.HomeConfig` and is loaded at startup via the standard `gears.config` JSON column.
162+
142163
### Gearbox Agent Gears
143164

144165
Agent gears run on monitored servers (in the **gearbox-agent** application) and collect data, expose APIs, and respond to management commands.

gearbox/.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ coverage.out
1717
coverage.html
1818

1919
# Dependency directories
20-
vendor/
20+
# (Anchored to the module root so static/js/vendor and static/css/vendor — used
21+
# for vendored frontend libraries like gridstack — remain tracked.)
22+
/vendor/
2123

2224
# Go workspace file
2325
go.work

gearbox/Makefile

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build test test-coverage lint clean clean-data run dev templ-generate fmt tidy deps install-tools dev-assets
1+
.PHONY: help build test test-coverage lint clean clean-data run dev templ-generate fmt tidy deps install-tools dev-assets deploy deploy-build deploy-restart
22

33
# Variables
44
APP_NAME := gearbox
@@ -7,6 +7,14 @@ COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
77
BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
88
LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION) -X main.CommitSHA=$(COMMIT_SHA) -X main.BuildDate=$(BUILD_DATE)"
99

10+
# Deploy configuration — overridable on the command line:
11+
# make deploy MJOLNIR_HOST=otherbox@10.0.0.99
12+
MJOLNIR_HOST ?= dave@10.0.0.1
13+
MJOLNIR_HOMELAB_PATH ?= /mnt/StormEdge/apps/homelab
14+
# Match the tag the homelab compose pulls so docker compose up -d picks
15+
# the freshly-loaded image without any compose-file edits.
16+
DEPLOY_IMAGE_TAG ?= ghcr.io/sarg3nt/gearbox/gearbox:dev
17+
1018
help: ## Show this help message
1119
@echo 'Usage: make [target]'
1220
@echo ''
@@ -113,3 +121,37 @@ dev-assets: ## Download CDN assets locally for secure development (CSP-compliant
113121
@echo ""
114122
@echo "To use local assets (CSP-compliant), set in your .env:"
115123
@echo " USE_LOCAL_ASSETS=true"
124+
125+
# ---------------------------------------------------------------------------
126+
# Deployment — push the local working copy to mjolnir as a Docker image,
127+
# bypassing the GitHub Actions registry round-trip. Useful for fast feature-
128+
# branch testing.
129+
#
130+
# Build target is linux/amd64 (mjolnir runs TrueNAS SCALE on x86_64).
131+
# Build runs through Orbstack on the dev box; no buildx setup required.
132+
# The image is loaded into mjolnir's Docker daemon directly via SSH, then
133+
# the gearbox compose stack is recreated. ~30s round trip when the layer
134+
# cache is warm; no public push of WIP code.
135+
#
136+
# Override on the command line: `make deploy MJOLNIR_HOST=user@host`
137+
# ---------------------------------------------------------------------------
138+
139+
deploy: deploy-build deploy-restart ## Build linux/amd64 image and deploy to mjolnir (build + ship + restart)
140+
@echo "✓ Deployed $(APP_NAME) ($(VERSION) / $(COMMIT_SHA)) to $(MJOLNIR_HOST)"
141+
@echo " Tail logs with: ssh $(MJOLNIR_HOST) 'sudo docker logs -f gearbox'"
142+
143+
deploy-build: templ-generate ## Build the linux/amd64 image and stream it to mjolnir
144+
@echo "Building $(DEPLOY_IMAGE_TAG) for linux/amd64..."
145+
@docker build \
146+
--platform linux/amd64 \
147+
--build-arg VERSION=$(VERSION) \
148+
--build-arg COMMIT_SHA=$(COMMIT_SHA) \
149+
--build-arg BUILD_DATE=$(BUILD_DATE) \
150+
--tag $(DEPLOY_IMAGE_TAG) \
151+
.
152+
@echo "Streaming image to $(MJOLNIR_HOST) (gzipped over SSH)..."
153+
@docker save $(DEPLOY_IMAGE_TAG) | gzip | ssh $(MJOLNIR_HOST) 'gunzip | sudo docker load'
154+
155+
deploy-restart: ## Recreate the gearbox container on mjolnir using the homelab compose file
156+
@echo "Recreating gearbox container on $(MJOLNIR_HOST)..."
157+
@ssh $(MJOLNIR_HOST) 'sudo docker compose -p gearbox -f $(MJOLNIR_HOMELAB_PATH)/apps/gearbox/docker-compose.yml up -d'

gearbox/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ A modern, self-contained monitoring dashboard for HAProxy reverse proxy servers.
2929
- 📊 **Real-time Performance Metrics** - Live HAProxy statistics, throughput, and connection monitoring
3030
- 🔍 **Configuration-Aware** - Understands custom haproxy-autoconfig labels and displays full backend configuration
3131
- 🎯 **Multi-Server Support** - Monitor multiple HAProxy instances from a single dashboard
32+
- 🏠 **Home Dashboard Gear** - Drag-and-drop start page with launcher tiles, live widgets (Sonarr / Radarr / Plex / UniFi / Pi-hole / qBittorrent / …), inline sparkline graphs, server-side reachability checks, and encrypted per-tile API keys. See [Home gear docs](docs/home-gear.md).
3233
- 📱 **Mobile-Responsive UI** - Modern, beautiful interface that works on desktop, tablet, and mobile
3334
- 🔐 **Secure Authentication** - Session-based authentication with encrypted cookies
3435
- 📈 **System Metrics** - CPU, memory, disk, and network usage from HAProxy servers
@@ -290,9 +291,10 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for detailed architecture docum
290291

291292
## Documentation
292293

293-
- [SETUP.md](docs/SETUP.md) - Detailed setup instructions
294-
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - System architecture and design
295-
- [API.md](docs/API.md) - Internal API documentation (if applicable)
294+
- [docs/home-gear.md](docs/home-gear.md) - Home dashboard gear: widgets, sparklines, security model
295+
- [docs/development.md](docs/development.md) - Local development guide
296+
- [docs/local-development-csp.md](docs/local-development-csp.md) - CSP notes for local dev
297+
- [internal/gears/home/README.md](internal/gears/home/README.md) - Home gear: implementation reference (routes, schema, providers)
296298

297299
## Contributing
298300

gearbox/cmd/server/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
_ "github.com/sarg3nt/gearbox/internal/gears/alerts"
3434
_ "github.com/sarg3nt/gearbox/internal/gears/certificates"
3535
_ "github.com/sarg3nt/gearbox/internal/gears/haproxy"
36+
_ "github.com/sarg3nt/gearbox/internal/gears/home"
3637
_ "github.com/sarg3nt/gearbox/internal/gears/logs"
3738
_ "github.com/sarg3nt/gearbox/internal/gears/metrics"
3839
_ "github.com/sarg3nt/gearbox/internal/gears/services"
@@ -80,6 +81,13 @@ func main() {
8081
"path", cfg.DatabasePath,
8182
"retention_hours", cfg.DatabaseRetentionHours)
8283

84+
// Seed default rows for system-wide gears (e.g. the Home dashboard).
85+
// Box-scoped gears are seeded lazily on first access; system gears
86+
// have no box to attach to, so we seed them once here.
87+
if err := db.EnsureSystemGears(); err != nil {
88+
logger.Error("failed to seed system gears", "error", err)
89+
}
90+
8391
// Ensure admin user exists
8492
var adminPassword string
8593
var adminPasswordHash string
@@ -355,6 +363,7 @@ func main() {
355363

356364
// Create gear dependencies
357365
authAdapter := services.NewAuthAdapter(authManager)
366+
authAdapter.SetEncryptor(encryptor)
358367
eventsAdapter := services.NewEventsAdapter(eventHub)
359368
serverAdapter := services.NewServerAdapter(db, encryptor, servers, logger)
360369

@@ -503,6 +512,10 @@ func main() {
503512
// Logout
504513
r.Post("/logout", h.Logout)
505514

515+
// Root URL — redirect to the user's default-landing-path
516+
// (per-user → system → fallback). See feature/dashboard-gear F1.
517+
r.Get("/", h.RootRedirect)
518+
506519
// Settings routes
507520
r.Route("/settings", func(r chi.Router) {
508521
// Settings menu page (accessible by all authenticated users)

gearbox/docs/home-gear.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Home Gear (Dashboard)
2+
3+
The **Home** gear is Gearbox's box-agnostic app dashboard — a self-hosted
4+
start page with launcher tiles, service widgets, sparkline graphs,
5+
bookmarks, and a search bar. It's the first gear with
6+
`Info.Scope == ScopeSystem`: there is one row per install, not one per
7+
monitored box, since a dashboard isn't tied to a particular server.
8+
9+
> **Implementation reference:** the canonical, code-adjacent docs live
10+
> alongside the source at
11+
> [`internal/gears/home/README.md`](../internal/gears/home/README.md)
12+
> capabilities, routes, permissions, schema, and architecture notes.
13+
> This page is the **operator-facing overview** with quickstart and
14+
> common questions.
15+
16+
## Quick start
17+
18+
1. Sign in to Gearbox as an admin and enable the **Home** gear from
19+
`Settings → Gears`. (Home is system-scoped, so it's listed in the
20+
global gears section, not under any individual box.)
21+
2. Navigate to `/home/`. You'll see an empty board with an **+ Add tile**
22+
button in the top-right.
23+
3. Click **+ Add tile**, paste an app's URL (e.g. `https://sonarr.example.com`),
24+
tab out of the field. The backend probes well-known endpoints in
25+
parallel; on a fingerprint hit, a green "Detected: <App>" banner
26+
pre-fills name + icon + slug.
27+
4. For widget data, paste the upstream's API key into the **API Key**
28+
field. Click **API Instructions** in the detection banner if you
29+
need step-by-step guidance — works for Sonarr/Radarr/Prowlarr/
30+
Lidarr/Readarr/Bazarr, qBittorrent, Plex, Jellyfin, Tautulli,
31+
Pi-hole, AdGuard, Portainer, Immich, and UniFi.
32+
5. **Save**. The tile lands on the board and starts its 30-second
33+
refresh loop.
34+
35+
## Built-in widget providers
36+
37+
Apps with first-class widgets (live data pills, server-rendered field
38+
maps over SSE):
39+
40+
| App | Auth | Sample fields |
41+
|----------------------|-------------|--------------------------------------------------------|
42+
| Sonarr / Radarr | API key | `wanted`, `missing`, `queued`, `series` / `movies` |
43+
| Lidarr / Readarr | API key | `wanted`, `queued`, `artists` / `books` |
44+
| Prowlarr | API key | `numIndexers`, `numGrabs`, `numQueries`, `numFailQueries` |
45+
| qBittorrent | basic auth | `download`, `upload`, `leech`, `seed` |
46+
| Pi-hole | API key | `queries`, `blocked`, `blocked_percent`, `gravity` |
47+
| Plex | `X-Plex-Token` | `streams`, `transcodes`, `bandwidth`, `movies`, `tv`, `episodes`, `libraries` |
48+
| UniFi Network | `X-API-KEY` (Integration API) | `clients`, `wifi`, `wired`, `vpn`, `devices_online`, `devices_offline`, `wan_status`, `wan_down` *(graph)*, `wan_up` *(graph)*, `gateway_cpu`, `gateway_mem`, `uptime` |
49+
50+
Apps in the catalog without a tier-1 provider still get launcher tiles
51+
(icon + name + reachability status). Adding a new provider is a
52+
small Go file; see the **Adding a Tier-1 widget provider** section in
53+
the gear's [internal README](../internal/gears/home/README.md).
54+
55+
## Sparkline graphs
56+
57+
Fields tagged `graphable: true` in the catalog (currently UniFi
58+
`wan_down` and `wan_up`) render an inline ~38×12 SVG trend line next
59+
to the value. The browser buffers the last 60 samples per
60+
tile×field in memory and scales each line to its buffer's min/max.
61+
Bandwidth values are normalized to bits/s before graphing so the
62+
trend stays smooth across Kbps↔Mbps unit boundaries.
63+
64+
The buffer is **per session** — sparklines start from when you opened
65+
the dashboard and grow as updates arrive. UniFi's Integration API
66+
doesn't ship historical time-series, so anything older than the
67+
current session isn't available. After ~2 minutes of dashboard
68+
uptime you have 4 samples (1 every 30 s); after 30 minutes the buffer
69+
is at its 60-sample cap and starts rolling.
70+
71+
## Security model (where do my API keys live?)
72+
73+
API keys, basic-auth passwords, and bearer tokens are:
74+
75+
- **Encrypted at rest** with the install's master key via the existing
76+
AES-256-GCM `crypto.Encryptor` (the same primitive used for agent
77+
API keys). Keys are stored in the `home_tile_secrets` table,
78+
separate from `home_tiles` so the secret can be `JOIN`ed only by
79+
code paths that need it.
80+
- **Used only by backend handlers**. All third-party API calls
81+
(Sonarr, Plex, UniFi, etc.) are made by the Gearbox Go process
82+
with the decrypted key in headers. The browser never has the key.
83+
- **Never serialized into responses or templ renders**. The widget
84+
refresh sends the *rendered field map* (`{"streams": "2",
85+
"bandwidth": "12.4 Mbps"}`) over SSE — the upstream's raw response
86+
body never reaches the browser.
87+
- **Surfaced to the UI as `has_secret: true` only**. To see a stored
88+
key after creation, you re-enter it (Stripe-style); there is no
89+
"show key" button.
90+
91+
If you compromise the Gearbox host, you can read the in-memory cache
92+
and decrypt at-rest secrets with the master key — that's the same
93+
threat model as every other secret in the app.
94+
95+
## Configuration
96+
97+
System-wide settings live on the gear's own `gears.config` JSON row
98+
(`server_id = '__system__'`, `name = 'home'`):
99+
100+
```json
101+
{
102+
"system_default_landing_path": "/home",
103+
"health_checks_enabled": true,
104+
"default_status_interval_seconds": 30,
105+
"attribution_shown": false
106+
}
107+
```
108+
109+
Per-tile cadence overrides live on the tile's own config JSON
110+
(`AppConfig.status_interval_seconds`, `AppConfig.status_checks_disabled`).
111+
112+
## Backup / restore
113+
114+
The gear ships with schema-versioned import/export at
115+
`/home/api/export` and `/home/api/import`. The JSON file is portable
116+
across installs, but **encrypted secrets are deliberately excluded**
117+
— carrying them across hosts would require sharing the master key,
118+
and we'd rather you re-enter keys on the destination than punch a
119+
hole in the threat model.

gearbox/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/sarg3nt/gearbox
33
go 1.25.5
44

55
require (
6-
github.com/a-h/templ v0.3.977
6+
github.com/a-h/templ v0.3.1001
77
github.com/go-chi/chi/v5 v5.2.4
88
github.com/go-webauthn/webauthn v0.15.0
99
github.com/golang-migrate/migrate/v4 v4.19.1

gearbox/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
22
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
3+
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
4+
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
35
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
46
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
57
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=

gearbox/internal/framework/database/database.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ func New(dbPath string, logger *slog.Logger) (*DB, error) {
9090
return nil, fmt.Errorf("failed to initialize config schema: %w", err)
9191
}
9292

93+
// Initialize Home dashboard schema (boards, tiles, secrets)
94+
if err := d.initHomeSchema(); err != nil {
95+
return nil, fmt.Errorf("failed to initialize home schema: %w", err)
96+
}
97+
9398
// Run schema migrations AFTER all schemas are initialized
9499
// (migrations may reference tables from any schema)
95100
if err := d.runSchemaMigrations(); err != nil {

0 commit comments

Comments
 (0)