6.5 KiB
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
externalper 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_servicesgains a nullableexternal textcolumn.config/services.jsonservices gain an optional"external"string.- Two existing services that store a domain in
urlare normalised:urlbecomes the LAN address,externalbecomes the domain:jellyfin:url→http://192.168.1.230:8096,external→https://jellyfin.hynesy.comchaptarr:url→http://192.168.1.230:8789,external→https://chaptarr.hynesy.com
Server
lib/db/repos/monitored_services.js: addexternaltoCOLS,toSvc,create(defaultnull),PATCHABLE, and theupsertDiscoveredinsert (alwaysnull— discovered LAN scans have no domain).lib/api/routes/health.js:- Add
external: s.externalto the per-service object in theGET /servicespayload. - Add
external: z.string().url().optional()tosvcBody(so owner add/edit can set it).
- Add
- Migration
lib/db/migrations/022_monitored_services_external.sql:ALTER TABLE monitored_services ADD COLUMN external text;plusUPDATE ... SET external=... WHERE id=...backfill for the curated IDs — because the live instance is already seeded (seedFromConfigis 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 ranges10.,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. altis null when it would equalprimary.
- remote:
Tile (public/components/service_tile.js)
- Compute
{ primary, alt, lanOnly }viapickServiceUrls(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 atitlenaming the alt ("Open via LAN" / "Open via domain"). Click must not also trigger the main anchor. - If
lanOnly: addlan-onlyclass (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. safeHrefalready guards bad schemes; both primary and alt pass through it.- Existing seeded DB rows: migration 022 adds
external(NULL) AND backfills the curated domains byid, so the deployed (already-seeded) instance gets them without a re-seed. Fresh/empty instances also get them viaservices.jsonon first seed.
Testing
- Unit (pure):
isRemoteHost(private/public/localhost/.local/bare cases) andpickServiceUrls(remote+external, remote+none, local+external, local+none, alt-equals- primary dedupe). - API:
GET /servicesincludesexternal;POST /servicesaccepts/round-tripsexternal. - Tile/contract: tile renders alt control when alt present;
lan-onlyclass + badge when remote and no external; main href is primary. (jsdom;location.hostnamestubbed.) - Repo:
create/updatepersist and returnexternal.
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).