Files
Void-Homelab/docs/superpowers/specs/2026-06-08-service-tile-local-remote-design.md
2026-06-08 00:52:26 +10:00

6.5 KiB
Raw Permalink Blame History

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.jshref: 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: urlhttp://192.168.1.230:8096, externalhttps://jellyfin.hynesy.com
    • chaptarr: urlhttp://192.168.1.230:8789, externalhttps://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.1631., 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).