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

119 lines
6.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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).