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:
root
2026-06-08 00:52:26 +10:00
parent 03a93ade1f
commit 276889e7fe
2 changed files with 630 additions and 0 deletions

View File

@@ -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 `<a>` and a small sibling alt `<a>` (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 `<a>` 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 <a>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 `<a>`, 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 `<a>` to `<div>` may break an existing
assertion or CSS selector that targeted `a.tile` — T8 step 1 explicitly catches this.

View File

@@ -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.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).