# 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.