diff --git a/docs/superpowers/plans/2026-06-08-service-tile-local-remote.md b/docs/superpowers/plans/2026-06-08-service-tile-local-remote.md new file mode 100644 index 0000000..183024b --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-service-tile-local-remote.md @@ -0,0 +1,512 @@ +# Local/remote-aware service tiles — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Service tiles open the LAN URL when on-network and the domain when remote, with an always-available one-click alt to the other URL, and a dimmed "LAN-only" marker for services without a domain. + +**Architecture:** Add an optional `external` URL per service (DB column + `services.json` + API payload). A pure client helper picks primary/alt URLs from `location.hostname`. The tile becomes a `div` with a stretched primary `` and a small sibling alt `` (avoids invalid nested anchors). Health checker is untouched. + +**Tech Stack:** Node/Express, Postgres (numbered SQL migrations in `lib/db/migrations/`), vanilla-JS frontend (`public/`), Vitest + jsdom. + +**Spec:** `docs/superpowers/specs/2026-06-08-service-tile-local-remote-design.md` + +--- + +### Task 1: Migration 022 — `external` column + backfill + +**Files:** +- Create: `lib/db/migrations/022_monitored_services_external.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- 022_monitored_services_external.sql +-- Optional external/domain URL for a service, used by the dashboard tile when the +-- owner is browsing remotely. LAN `url` stays the source of truth for health checks. +ALTER TABLE monitored_services ADD COLUMN external text; + +-- Backfill curated domains by id (the live instance is already seeded, so adding the +-- column alone wouldn't populate them). Safe no-op for ids that don't exist. +UPDATE monitored_services SET external = 'https://void.hynesy.com' WHERE id = 'void-server'; +UPDATE monitored_services SET external = 'https://gramps.hynesy.com' WHERE id = 'gramps'; +UPDATE monitored_services SET external = 'https://plex.hynesy.com' WHERE id = 'plex'; +UPDATE monitored_services SET external = 'https://tdarr.hynesy.com' WHERE id = 'tdarr'; +UPDATE monitored_services SET external = 'https://sonarr.hynesy.com' WHERE id = 'sonarr'; +UPDATE monitored_services SET external = 'https://radarr.hynesy.com' WHERE id = 'radarr'; +UPDATE monitored_services SET external = 'https://bookstack.hynesy.com' WHERE id = 'bookstack'; +-- Two services previously stored their domain in `url`; normalise to LAN url + external. +UPDATE monitored_services SET url = 'http://192.168.1.230:8096', external = 'https://jellyfin.hynesy.com' WHERE id = 'jellyfin'; +UPDATE monitored_services SET url = 'http://192.168.1.230:8789', external = 'https://chaptarr.hynesy.com' WHERE id = 'chaptarr'; +``` + +- [ ] **Step 2: Verify it applies cleanly** + +Run: `npm run migrate` (or the project's migrate entrypoint — check `package.json` "scripts"; fall back to `node -e "import('./lib/db/migrate.js').then(m=>m.migrateUp())"`). +Expected: log line `applying migration … 022_monitored_services_external.sql`, no error. Re-running is a no-op (already in `schema_migrations`). + +- [ ] **Step 3: Commit** + +```bash +git add lib/db/migrations/022_monitored_services_external.sql +git commit -m "feat(health): add external URL column + backfill curated domains" +``` + +--- + +### Task 2: Repo support for `external` + +**Files:** +- Modify: `lib/db/repos/monitored_services.js` +- Test: `tests/health/registry.test.js` (or nearest repo test) — add a round-trip case + +- [ ] **Step 1: Write the failing test** (append to the repo/registry test file) + +```js +import { describe, it, expect } from 'vitest'; +import * as repo from '../../lib/db/repos/monitored_services.js'; + +describe('monitored_services external', () => { + it('persists and returns external on create/get/update', async () => { + const id = 'ext-test-' + Date.now(); + await repo.create({ id, name: 'Ext', url: 'http://10.0.0.1', external: 'https://ext.example.com' }); + expect((await repo.get(id)).external).toBe('https://ext.example.com'); + const upd = await repo.update(id, { external: 'https://ext2.example.com' }); + expect(upd.external).toBe('https://ext2.example.com'); + await repo.remove(id); + }); +}); +``` + +- [ ] **Step 2: Run it, watch it fail** + +Run: `npx vitest run tests/health/registry.test.js` +Expected: FAIL — `external` is `undefined` (column/field not wired). + +- [ ] **Step 3: Wire `external` through the repo** + +In `lib/db/repos/monitored_services.js`: +- `COLS`: add `external` → + `const COLS = 'id, name, category, host, url, icon, external, check_cfg, source, enabled';` +- `toSvc`: add `external: r.external,` (e.g. right after `url: r.url,`). +- `create`: destructure `external = null` in the defaults and add it to the column list + values: + +```js +export async function create(svc) { + const { id, name, category = 'other', host = null, url, icon = null, + external = null, check = {}, source = 'manual', enabled = true } = svc; + const { rows: [r] } = await pool.query( + `INSERT INTO monitored_services (id, name, category, host, url, icon, external, check_cfg, source, enabled) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$10) RETURNING ${COLS}`, + [id, name, category, host, url, icon, external, JSON.stringify(check), source, enabled]); + return toSvc(r); +} +``` + +- `PATCHABLE`: add `'external'` → + `const PATCHABLE = ['name', 'category', 'host', 'url', 'icon', 'external', 'enabled'];` +- `upsertDiscovered`: add `external` to the column list with a literal `null` (discovered scans have no domain). Update its INSERT column list to include `external` and the SELECT to pass `null` in that position. Concretely, change the INSERT to: + +```js + `INSERT INTO monitored_services (id, name, category, host, url, icon, external, check_cfg, source, enabled) + SELECT $1,$2,$3,$4,$5,$6,NULL,$7::jsonb,'discovered',false + WHERE NOT EXISTS (SELECT 1 FROM monitored_services WHERE url=$5)` +``` +(Keep the rest of `upsertDiscovered` — params `$1..$7` — unchanged.) + +- [ ] **Step 4: Run the test, watch it pass** + +Run: `npx vitest run tests/health/registry.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/db/repos/monitored_services.js tests/health/registry.test.js +git commit -m "feat(health): thread external through monitored_services repo" +``` + +--- + +### Task 3: API — expose & accept `external` + +**Files:** +- Modify: `lib/api/routes/health.js` +- Test: `tests/api/health.test.js` + +- [ ] **Step 1: Write the failing test** (add to the health API test file) + +```js +it('GET /services includes external in tiles', async () => { + const res = await request(app).get('/api/health/services'); + const all = res.body.flatMap(g => g.services); + // every tile object has the key (value may be null) + expect(all.every(s => 'external' in s)).toBe(true); +}); +``` +(Use the file's existing app/request setup and any owner-auth header pattern already in this test file.) + +- [ ] **Step 2: Run it, watch it fail** + +Run: `npx vitest run tests/api/health.test.js` +Expected: FAIL — tiles have no `external` key. + +- [ ] **Step 3: Add `external` to payload + body schema** + +In `lib/api/routes/health.js`: +- In the `GET /services` per-service object, add `external: s.external` (next to `url: s.url`): + +```js + return { + id: s.id, name: s.name, host: s.host, url: s.url, external: s.external ?? null, icon: iconSlug(s), + status: st?.status || 'unknown', latency_ms: st?.latency_ms ?? null, + detail: st?.detail || null, checked_at: st?.checked_at || null + }; +``` + +- In `svcBody`, add the optional field (after `url`): + +```js + url: z.string().url(), + external: z.string().url().optional(), +``` + +- [ ] **Step 4: Run the test, watch it pass** + +Run: `npx vitest run tests/api/health.test.js` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/api/routes/health.js tests/api/health.test.js +git commit -m "feat(health): expose external in /services payload and accept it on add/edit" +``` + +--- + +### Task 4: Seed data — `external` in `config/services.json` + +**Files:** +- Modify: `config/services.json` + +- [ ] **Step 1: Add `external` and normalise the two domain-as-url rows** + +Edit `config/services.json`: +- Add `"external"` to these rows (keep their existing `url`): + - `void-server` → `"external": "https://void.hynesy.com"` + - `gramps` → `"external": "https://gramps.hynesy.com"` + - `plex` → `"external": "https://plex.hynesy.com"` + - `tdarr` → `"external": "https://tdarr.hynesy.com"` + - `sonarr` → `"external": "https://sonarr.hynesy.com"` + - `radarr` → `"external": "https://radarr.hynesy.com"` + - `bookstack` → `"external": "https://bookstack.hynesy.com"` +- Change these two so `url` is the LAN address and `external` is 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"` + +(All other services keep just `url` — they're LAN-only until the owner adds a domain.) + +- [ ] **Step 2: Verify it's valid JSON and externals are URLs** + +Run: +```bash +cd /project/src/void-v2 && node -e " +const s=require('./config/services.json'); +const ext=s.filter(x=>x.external); +for(const x of ext){ new URL(x.external); } +console.log('valid JSON, externals:', ext.length, ext.map(x=>x.id).join(',')); +" +``` +Expected: prints `valid JSON, externals: 9 void-server,gramps,bookstack,plex,jellyfin,tdarr,sonarr,radarr,chaptarr` (order may vary), no throw. + +- [ ] **Step 3: Commit** + +```bash +git add config/services.json +git commit -m "feat(health): seed external domains for exposed services" +``` + +--- + +### Task 5: Pure client helper `service_url.js` (TDD) + +**Files:** +- Create: `public/views/service_url.js` +- Create: `tests/frontend/service_url.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { isRemoteHost, pickServiceUrls } from '../../public/views/service_url.js'; + +describe('isRemoteHost', () => { + it('treats private/local hosts as not-remote', () => { + for (const h of ['localhost', '127.0.0.1', '192.168.1.216', '10.0.0.5', '172.16.0.1', 'void.local', 'ct311', 'nas.lan']) + expect(isRemoteHost(h)).toBe(false); + }); + it('treats public domains as remote', () => { + for (const h of ['void.hynesy.com', 'dash.example.org']) + expect(isRemoteHost(h)).toBe(true); + }); +}); + +describe('pickServiceUrls', () => { + const svc = { url: 'http://192.168.1.99', external: 'https://gramps.hynesy.com' }; + it('remote + external → domain primary, LAN alt', () => { + expect(pickServiceUrls(svc, true)).toEqual({ primary: 'https://gramps.hynesy.com', alt: 'http://192.168.1.99', lanOnly: false }); + }); + it('local + external → LAN primary, domain alt', () => { + expect(pickServiceUrls(svc, false)).toEqual({ primary: 'http://192.168.1.99', alt: 'https://gramps.hynesy.com', lanOnly: false }); + }); + it('remote + no external → LAN primary, no alt, lanOnly', () => { + expect(pickServiceUrls({ url: 'http://192.168.1.5' }, true)).toEqual({ primary: 'http://192.168.1.5', alt: null, lanOnly: true }); + }); + it('local + no external → LAN primary, no alt', () => { + expect(pickServiceUrls({ url: 'http://192.168.1.5' }, false)).toEqual({ primary: 'http://192.168.1.5', alt: null, lanOnly: false }); + }); +}); +``` + +- [ ] **Step 2: Run it, watch it fail** + +Run: `npx vitest run tests/frontend/service_url.test.js` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the helper** + +```js +// Pure helpers: choose which URL a service tile opens, based on whether the dashboard +// is being viewed on-network (LAN) or remotely. No DOM / no network access here. + +const PRIVATE_RE = /^(localhost$|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/; + +// True when `hostname` is NOT a private/local address — i.e. the dashboard is being +// accessed remotely (e.g. via void.hynesy.com). +export function isRemoteHost(hostname = '') { + const h = String(hostname).toLowerCase(); + if (!h) return false; + if (PRIVATE_RE.test(h)) return false; + if (h.endsWith('.local') || h.endsWith('.lan')) return false; + if (!h.includes('.')) return false; // bare hostname (e.g. "ct311") + return true; +} + +// For a service ({ url, external }) and whether we're remote, return the primary URL +// the tile opens, an optional alt (the other URL) for one-click fallback, and whether +// the service is LAN-only while remote. +export function pickServiceUrls(svc = {}, remote = false) { + const url = svc.url || ''; + const external = svc.external || ''; + let primary, alt; + if (remote) { primary = external || url; alt = external ? url : ''; } + else { primary = url; alt = external || ''; } + if (alt && alt === primary) alt = ''; + return { primary, alt: alt || null, lanOnly: remote && !external }; +} +``` + +- [ ] **Step 4: Run the test, watch it pass** + +Run: `npx vitest run tests/frontend/service_url.test.js` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add public/views/service_url.js tests/frontend/service_url.test.js +git commit -m "feat(health): pure helper to pick local/remote service URLs" +``` + +--- + +### Task 6: Tile rendering — primary link, alt control, LAN-only marker + +**Files:** +- Modify: `public/components/service_tile.js` +- Modify: `public/views/health_band.js` (pass a single `remote` flag into the map) +- Test: `tests/frontend/card_contract.test.js` (add tile structure assertions) or a new `tests/frontend/service_tile.test.js` + +- [ ] **Step 1: Write the failing test** (new file `tests/frontend/service_tile.test.js`) + +```js +import { describe, it, expect } from 'vitest'; +import { serviceTile } from '../../public/components/service_tile.js'; + +const base = { id: 'gramps', name: 'Gramps', host: 'ct109', icon: 'gramps', status: 'ok', + url: 'http://192.168.1.99', external: 'https://gramps.hynesy.com' }; + +describe('serviceTile', () => { + it('local: primary link is the LAN url, alt link is the domain', () => { + const t = serviceTile(base, false); + const links = t.querySelectorAll('a'); + expect(t.querySelector('.tile-link').getAttribute('href')).toBe('http://192.168.1.99/'); + expect(t.querySelector('.tile-alt').getAttribute('href')).toBe('https://gramps.hynesy.com/'); + expect(links.length).toBe(2); + }); + it('remote: primary is the domain, alt is the LAN url', () => { + const t = serviceTile(base, true); + expect(t.querySelector('.tile-link').getAttribute('href')).toBe('https://gramps.hynesy.com/'); + expect(t.querySelector('.tile-alt').getAttribute('href')).toBe('http://192.168.1.99/'); + expect(t.classList.contains('lan-only')).toBe(false); + }); + it('remote + no external: lan-only, no alt, badge present', () => { + const t = serviceTile({ ...base, external: undefined }, true); + expect(t.classList.contains('lan-only')).toBe(true); + expect(t.querySelector('.tile-alt')).toBeNull(); + expect(t.querySelector('.tile-lan')).not.toBeNull(); + }); +}); +``` +(jsdom resolves `href` to absolute form, hence the trailing-slash expectations — keep them as written; adjust only if the project's `safeHref`/jsdom normalises differently when you run it.) + +- [ ] **Step 2: Run it, watch it fail** + +Run: `npx vitest run tests/frontend/service_tile.test.js` +Expected: FAIL — `serviceTile` ignores the 2nd arg and renders one `` root with no `.tile-link`/`.tile-alt`. + +- [ ] **Step 3: Rewrite `service_tile.js`** + +```js +import { el, safeHref } from '../dom.js'; +import { isRemoteHost, pickServiceUrls } from '../views/service_url.js'; + +// `remote` is injected by the caller (defaults to detecting from the current host) so +// the component stays unit-testable without stubbing window.location. +export function serviceTile(s, remote = isRemoteHost(location.hostname)) { + const { primary, alt, lanOnly } = pickServiceUrls(s, remote); + + const img = el('img', { class: 'tile-icon', loading: 'lazy', src: `/api/icons/${s.icon}.png`, alt: s.name }); + img.onerror = () => img.replaceWith(el('div', { class: 'tile-icon-fb' }, (s.name[0] || '?').toUpperCase())); + + // Root is a div so we can host two sibling s (a stretched primary + a small alt) + // without nesting anchors (invalid HTML). + const tile = el('div', { class: `tile status-${s.status}${lanOnly ? ' lan-only' : ''}` }, + img, + el('div', { class: 'tile-main' }, + el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name), + el('div', { class: 'tile-host' }, s.host || '')), + el('span', { class: 'tile-go' }, 'open ↗'), + // Stretched primary link covers the whole tile (see .tile-link in style.css). + el('a', { class: 'tile-link', href: safeHref(primary), target: '_blank', rel: 'noreferrer', + 'aria-label': `Open ${s.name}` })); + + if (lanOnly) tile.appendChild(el('span', { class: 'tile-lan', title: 'Not reachable remotely' }, 'LAN-only')); + + if (alt) { + tile.appendChild(el('a', { + class: 'tile-alt', href: safeHref(alt), target: '_blank', rel: 'noreferrer', + title: remote ? 'Open via LAN' : 'Open via domain', + }, '⇄')); + } + return tile; +} +``` + +- [ ] **Step 4: Pass a single `remote` flag from the band** + +In `public/views/health_band.js`: +- Add to the imports at top: + `import { isRemoteHost } from './service_url.js';` +- In `load()`, before building `sections`, compute once: + `const remote = isRemoteHost(location.hostname);` +- Change the tile map from `el('div', { class: 'tiles' }, g.services.map(serviceTile))` + to `el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))`. + +- [ ] **Step 5: Run the test, watch it pass** + +Run: `npx vitest run tests/frontend/service_tile.test.js` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add public/components/service_tile.js public/views/health_band.js tests/frontend/service_tile.test.js +git commit -m "feat(health): tile opens local/remote URL with one-click alt + LAN-only marker" +``` + +--- + +### Task 7: Styling (blackflame, aligned to existing tiles) + +**Files:** +- Modify: `public/style.css` + +- [ ] **Step 1: Find the existing tile styles for reference** + +Run: `rg -n "\.tile\b|\.tile-go|\.tile-host|\.tile-icon" public/style.css | head` +Read the surrounding rules so the new ones reuse the same variables/spacing/radius. + +- [ ] **Step 2: Add rules (adapt colors to the existing themed CSS variables you just read — do NOT hardcode new colors if a token exists)** + +```css +/* Tile root is now a positioned div hosting a stretched primary link + alt control. */ +.tile { position: relative; } +.tile-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; } +.tile-alt { + position: absolute; top: 6px; right: 6px; z-index: 2; + display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; font-size: 12px; line-height: 1; + border-radius: 6px; opacity: 0; transition: opacity .12s ease; + color: var(--fg-muted, #9aa); background: var(--surface-2, rgba(255,255,255,.06)); +} +.tile:hover .tile-alt, .tile:focus-within .tile-alt { opacity: 1; } +.tile-alt:hover { color: var(--accent, #5cf); } +.tile.lan-only { opacity: .55; } +.tile-lan { + position: absolute; bottom: 6px; right: 8px; z-index: 2; + font-size: 9px; letter-spacing: .04em; text-transform: uppercase; + color: var(--fg-muted, #9aa); pointer-events: none; +} +``` + +- [ ] **Step 3: Verify in the browser (webapp-testing skill)** + +Use the `webapp-testing` skill (Playwright) to load the dashboard and screenshot the +Little Blue band: confirm tiles look unchanged at rest, the `⇄` alt appears on hover, +and a LAN-only tile (simulate remote by visiting via the public host, or temporarily +stub `isRemoteHost`) is dimmed with the badge. (If the live instance is down, defer this +step and note it — the unit tests already cover the logic.) + +- [ ] **Step 4: Commit** + +```bash +git add public/style.css +git commit -m "style(health): blackflame styling for tile alt control + LAN-only marker" +``` + +--- + +### Task 8: Full suite + final check + +- [ ] **Step 1: Run the whole test suite** + +Run: `npx vitest run` +Expected: all green (new + existing). If a pre-existing tile/contract test asserted the +tile root was an ``, update that assertion to the new `div`+`.tile-link` structure. + +- [ ] **Step 2: Lint/format if the project has it** + +Run: `npm run lint` (skip if no such script). + +- [ ] **Step 3: Commit any fixups** + +```bash +git add -A && git commit -m "test(health): align existing tile assertions with new structure" +``` + +--- + +## Self-review (completed during planning) + +- **Spec coverage:** external column+backfill (T1), repo (T2), API payload+schema (T3), + seed data + url normalisation (T4), pure detection/selection helper (T5), tile + primary/alt/LAN-only + band wiring (T6), styling (T7), suite (T8). All spec sections map. +- **Placeholders:** none — migration number resolved to 022, all code inline, external + values enumerated. Pre-filled domains flagged as owner-verifiable in the spec. +- **Name/type consistency:** `external` used consistently (DB col, repo COLS/toSvc/create/ + PATCHABLE/upsertDiscovered, API payload+`svcBody`, `services.json`). Helper exports + `isRemoteHost`/`pickServiceUrls` with shape `{ primary, alt, lanOnly }` consumed verbatim + by `service_tile.js`. `serviceTile(s, remote)` signature matches the band's call site. +- **Risk note:** changing the tile root from `` to `
` may break an existing + assertion or CSS selector that targeted `a.tile` — T8 step 1 explicitly catches this. diff --git a/docs/superpowers/specs/2026-06-08-service-tile-local-remote-design.md b/docs/superpowers/specs/2026-06-08-service-tile-local-remote-design.md new file mode 100644 index 0000000..35d17e0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-service-tile-local-remote-design.md @@ -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).