21 KiB
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
-- 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
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)
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
externalthrough the repo
In lib/db/repos/monitored_services.js:
COLS: addexternal→const COLS = 'id, name, category, host, url, icon, external, check_cfg, source, enabled';toSvc: addexternal: r.external,(e.g. right afterurl: r.url,).create: destructureexternal = nullin the defaults and add it to the column list + values:
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: addexternalto the column list with a literalnull(discovered scans have no domain). Update its INSERT column list to includeexternaland the SELECT to passnullin that position. Concretely, change the INSERT to:
`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
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)
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
externalto payload + body schema
In lib/api/routes/health.js:
- In the
GET /servicesper-service object, addexternal: s.external(next tourl: s.url):
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 (afterurl):
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
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
externaland normalise the two domain-as-url rows
Edit config/services.json:
- Add
"external"to these rows (keep their existingurl):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
urlis the LAN address andexternalis 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:
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
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
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
// 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
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 singleremoteflag into the map) -
Test:
tests/frontend/card_contract.test.js(add tile structure assertions) or a newtests/frontend/service_tile.test.js -
Step 1: Write the failing test (new file
tests/frontend/service_tile.test.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
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
remoteflag from the band
In public/views/health_band.js:
-
Add to the imports at top:
import { isRemoteHost } from './service_url.js'; -
In
load(), before buildingsections, compute once:const remote = isRemoteHost(location.hostname); -
Change the tile map from
el('div', { class: 'tiles' }, g.services.map(serviceTile))toel('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
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)
/* 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
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
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:
externalused consistently (DB col, repo COLS/toSvc/create/ PATCHABLE/upsertDiscovered, API payload+svcBody,services.json). Helper exportsisRemoteHost/pickServiceUrlswith shape{ primary, alt, lanOnly }consumed verbatim byservice_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 targeteda.tile— T8 step 1 explicitly catches this.