docs(health): spec + plan for local/remote-aware service tiles
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# Local/remote-aware service tiles — design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved (design), pending plan
|
||||
**Component:** Void 2.0 Sacred Valley → Little Blue health band service tiles
|
||||
|
||||
## Problem
|
||||
|
||||
Every service in the health registry has a single `url`, almost always a LAN
|
||||
address (`http://192.168.1.x:port`, see `config/services.json`). The tile links
|
||||
straight to it (`public/components/service_tile.js` → `href: safeHref(s.url)`).
|
||||
When the owner opens the dashboard from off-network (e.g. via `void.hynesy.com`),
|
||||
those private IPs are unreachable, so tiles like **Gramps** (`http://192.168.1.99`)
|
||||
open dead links. A few services already paper over this by storing a domain as their
|
||||
`url` (Jellyfin, Chaptarr), which is inconsistent and breaks local-speed access.
|
||||
|
||||
## Goal
|
||||
|
||||
Tiles open the **right URL for where the owner is**: LAN address when on-network,
|
||||
domain when remote — with a reliable, always-available manual fallback to the other
|
||||
URL, and a clear "LAN-only" indication for services not exposed externally.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing how the **health checker** works. It runs server-side on CT 311 (on the
|
||||
LAN) and must keep probing the LAN `url`. This change only affects the **tile link**
|
||||
in the browser.
|
||||
- Automatic probe-then-fallback. Rejected — see Decisions.
|
||||
- Auto-discovering domains. The owner curates `external` per service.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
| Fork | Decision |
|
||||
|---|---|
|
||||
| Primary URL selection | **Context-based**: derived from `location.hostname` (public host ⇒ remote ⇒ domain; private IP/localhost/.local ⇒ local ⇒ LAN url) |
|
||||
| Domain data source | **Explicit optional `external` field** per service in `services.json`; pre-filled from CF/Traefik `*.hynesy.com` conventions, owner corrects |
|
||||
| "Fallback regardless" | **Manual one-click alt control** on each tile that opens the *other* URL. NOT an auto-probe |
|
||||
| Remote + no `external` | Tile shown but **dimmed + "LAN-only" badge** |
|
||||
| Styling | Blackflame-styled, sized/aligned to existing tile elements, reuse themed classes |
|
||||
|
||||
### Why not automatic probe-then-fallback
|
||||
|
||||
When remote, the dashboard is served over HTTPS. Silently probing an
|
||||
`http://192.168.x.x` address from an HTTPS page is blocked by the browser's
|
||||
mixed-content policy, and cross-origin (`no-cors`) probes return opaque results that
|
||||
can't distinguish "reachable" from "errored". So an auto-probe would be unreliable
|
||||
precisely when remote — the case it exists for. **Navigating** (clicking a link) to
|
||||
either URL is always allowed, so a manual alt-link is the robust realization of
|
||||
"fallback regardless".
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data
|
||||
- `monitored_services` gains a nullable `external text` column.
|
||||
- `config/services.json` services gain an optional `"external"` string.
|
||||
- Two existing services that store a domain in `url` are normalised: `url` becomes the
|
||||
LAN address, `external` becomes the domain:
|
||||
- `jellyfin`: `url` → `http://192.168.1.230:8096`, `external` → `https://jellyfin.hynesy.com`
|
||||
- `chaptarr`: `url` → `http://192.168.1.230:8789`, `external` → `https://chaptarr.hynesy.com`
|
||||
|
||||
### Server
|
||||
- `lib/db/repos/monitored_services.js`: add `external` to `COLS`, `toSvc`, `create`
|
||||
(default `null`), `PATCHABLE`, and the `upsertDiscovered` insert (always `null` —
|
||||
discovered LAN scans have no domain).
|
||||
- `lib/api/routes/health.js`:
|
||||
- Add `external: s.external` to the per-service object in the `GET /services` payload.
|
||||
- Add `external: z.string().url().optional()` to `svcBody` (so owner add/edit can set it).
|
||||
- Migration `lib/db/migrations/022_monitored_services_external.sql`: `ALTER TABLE
|
||||
monitored_services ADD COLUMN external text;` **plus** `UPDATE ... SET external=...
|
||||
WHERE id=...` backfill for the curated IDs — because the live instance is already
|
||||
seeded (`seedFromConfig` is a no-op once rows exist), so the column add alone wouldn't
|
||||
populate domains on deployed instances.
|
||||
|
||||
### Client (pure, unit-testable core)
|
||||
New module `public/views/service_url.js`:
|
||||
- `isRemoteHost(hostname)` → boolean. Remote = NOT (`localhost`, IPv4 private ranges
|
||||
`10.`, `172.16–31.`, `192.168.`, `127.`, `*.local`, `*.lan`, bare hostname with no dot).
|
||||
- `pickServiceUrls(svc, remote)` → `{ primary, alt, lanOnly }`:
|
||||
- remote: `primary = svc.external || svc.url`; `alt = svc.external ? svc.url : null`;
|
||||
`lanOnly = !svc.external`.
|
||||
- local: `primary = svc.url`; `alt = svc.external || null`; `lanOnly = false`.
|
||||
- `alt` is null when it would equal `primary`.
|
||||
|
||||
### Tile (`public/components/service_tile.js`)
|
||||
- Compute `{ primary, alt, lanOnly }` via `pickServiceUrls(s, isRemoteHost(location.hostname))`.
|
||||
- Main anchor → `safeHref(primary)`.
|
||||
- If `alt`: render a small secondary anchor (themed glyph, e.g. `⇄`) →
|
||||
`safeHref(alt)`, `target="_blank" rel="noreferrer"`, with a `title` naming the alt
|
||||
("Open via LAN" / "Open via domain"). Click must not also trigger the main anchor.
|
||||
- If `lanOnly`: add `lan-only` class (dim) + a small "LAN-only" badge.
|
||||
|
||||
### Pre-filled `external` values (owner to verify/correct)
|
||||
Confident (CF/Traefik confirmed): `void-server`→void, `gramps`, `plex`, `tdarr`,
|
||||
`sonarr`, `radarr`, `jellyfin`, `chaptarr`. Likely (wiki): `bookstack`. All others
|
||||
remain LAN-only until the owner adds a domain.
|
||||
|
||||
## Error handling / edge cases
|
||||
- Missing `external` → LAN-only path (no alt; dimmed when remote). No errors.
|
||||
- `safeHref` already guards bad schemes; both primary and alt pass through it.
|
||||
- Existing seeded DB rows: migration 022 adds `external` (NULL) AND backfills the curated
|
||||
domains by `id`, so the deployed (already-seeded) instance gets them without a re-seed.
|
||||
Fresh/empty instances also get them via `services.json` on first seed.
|
||||
|
||||
## Testing
|
||||
- Unit (pure): `isRemoteHost` (private/public/localhost/.local/bare cases) and
|
||||
`pickServiceUrls` (remote+external, remote+none, local+external, local+none, alt-equals-
|
||||
primary dedupe).
|
||||
- API: `GET /services` includes `external`; `POST /services` accepts/round-trips `external`.
|
||||
- Tile/contract: tile renders alt control when alt present; `lan-only` class + badge when
|
||||
remote and no external; main href is primary. (jsdom; `location.hostname` stubbed.)
|
||||
- Repo: `create`/`update` persist and return `external`.
|
||||
|
||||
## Files
|
||||
- Create: `lib/db/migrations/022_monitored_services_external.sql`,
|
||||
`public/views/service_url.js`, `tests/frontend/service_url.test.js`
|
||||
- Modify: `config/services.json`, `lib/db/repos/monitored_services.js`,
|
||||
`lib/api/routes/health.js`, `public/components/service_tile.js`, `public/style.css`,
|
||||
and the relevant existing tests (`tests/api/health.test.js`, tile/contract test).
|
||||
Reference in New Issue
Block a user