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

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 external through the repo

In lib/db/repos/monitored_services.js:

  • COLS: add externalconst 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:
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:
    `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 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):
      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):
  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 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:

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"

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)

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