` 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).
diff --git a/lib/api/routes/health.js b/lib/api/routes/health.js
index 2462728..4fe5aa0 100644
--- a/lib/api/routes/health.js
+++ b/lib/api/routes/health.js
@@ -17,7 +17,7 @@ router.get('/services', asyncWrap(async (_req, res) => {
const list = g.services.map(s => {
const st = statuses[s.id];
return {
- id: s.id, name: s.name, host: s.host, url: s.url, icon: iconSlug(s),
+ 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
};
@@ -39,6 +39,7 @@ const svcBody = z.object({
category: z.enum(['agents', 'infrastructure', 'media', 'other']).default('other'),
host: z.string().max(120).optional(),
url: z.string().url(),
+ external: z.string().url().optional(),
icon: z.string().max(64).optional(),
check: checkCfg.optional()
});
diff --git a/lib/db/migrations/022_monitored_services_external.sql b/lib/db/migrations/022_monitored_services_external.sql
new file mode 100644
index 0000000..f0f899f
--- /dev/null
+++ b/lib/db/migrations/022_monitored_services_external.sql
@@ -0,0 +1,17 @@
+-- 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). 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';
diff --git a/lib/db/repos/monitored_services.js b/lib/db/repos/monitored_services.js
index dcff7ba..6a212fe 100644
--- a/lib/db/repos/monitored_services.js
+++ b/lib/db/repos/monitored_services.js
@@ -1,12 +1,12 @@
import { pool } from '../pool.js';
-const COLS = 'id, name, category, host, url, icon, check_cfg, source, enabled';
+const COLS = 'id, name, category, host, url, icon, external, check_cfg, source, enabled';
// Map a DB row to the service shape the registry/checker expect (check_cfg -> check).
function toSvc(r) {
return {
id: r.id, name: r.name, category: r.category, host: r.host, url: r.url,
- icon: r.icon, check: r.check_cfg || {}, source: r.source, enabled: r.enabled
+ icon: r.icon, external: r.external ?? null, check: r.check_cfg || {}, source: r.source, enabled: r.enabled
};
}
@@ -42,15 +42,15 @@ export async function count() {
export async function create(svc) {
const { id, name, category = 'other', host = null, url, icon = null,
- check = {}, source = 'manual', enabled = true } = svc;
+ external = null, check = {}, source = 'manual', enabled = true } = svc;
const { rows: [r] } = await pool.query(
- `INSERT INTO monitored_services (id, name, category, host, url, icon, check_cfg, source, enabled)
- VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9) RETURNING ${COLS}`,
- [id, name, category, host, url, icon, JSON.stringify(check), source, enabled]);
+ `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);
}
-const PATCHABLE = ['name', 'category', 'host', 'url', 'icon', 'enabled'];
+const PATCHABLE = ['name', 'category', 'host', 'url', 'icon', 'external', 'enabled'];
export async function update(id, patch) {
const sets = [], vals = [];
for (const k of PATCHABLE) {
@@ -75,8 +75,8 @@ export async function remove(id) {
export async function upsertDiscovered(svc) {
const { id, name, category = 'other', host = null, url, icon = null, check = {} } = svc;
const { rows: [r] } = await pool.query(
- `INSERT INTO monitored_services (id, name, category, host, url, icon, check_cfg, source, enabled)
- SELECT $1,$2,$3,$4,$5,$6,$7::jsonb,'discovered',false
+ `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)
ON CONFLICT (id) DO NOTHING
RETURNING ${COLS}`,
diff --git a/package.json b/package.json
index 8b3a3d8..0894ddf 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "void-server",
- "version": "2.0.0-alpha.22",
+ "version": "2.0.0-alpha.23",
"type": "module",
"private": true,
"scripts": {
diff --git a/public/components/service_tile.js b/public/components/service_tile.js
index 8525ebe..9e71c17 100644
--- a/public/components/service_tile.js
+++ b/public/components/service_tile.js
@@ -1,11 +1,33 @@
import { el, safeHref } from '../dom.js';
-export function serviceTile(s) {
+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()));
- return el('a', { class: `tile status-${s.status}`, href: safeHref(s.url), target: '_blank', rel: 'noreferrer' },
+
+ // Root is a div so we can host two sibling
s (a stretched primary link + 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 ↗'));
+ 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;
}
diff --git a/public/style.css b/public/style.css
index f1b6bd7..3c1666b 100644
--- a/public/style.css
+++ b/public/style.css
@@ -450,6 +450,18 @@ ul.plain li:last-child { border-bottom: none; }
.tile.status-unknown .dot { background: var(--muted); }
.tile-go { color: var(--lb); font-size: 12px; opacity: 0; transition: opacity .25s; }
.tile:hover .tile-go { opacity: 1; }
+/* Tile root is a div hosting a stretched primary link + a small 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; text-decoration: none; opacity: 0; transition: opacity .2s;
+ color: var(--muted); background: #20202a; border: 1px solid var(--border); }
+.tile:hover .tile-alt, .tile:focus-within .tile-alt { opacity: 1; }
+.tile-alt:hover { color: var(--lb); border-color: #37404a; }
+.tile.lan-only { opacity: .55; }
+.tile-lan { position: absolute; bottom: 6px; right: 8px; z-index: 2; font-family: var(--font-mono);
+ font-size: 9px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); pointer-events: none; }
/* ===== Collapsible chrome + responsive layout (Plan 6 polish) ===== */
:root { --sidebar-w-min: 0px; }
diff --git a/public/views/health_band.js b/public/views/health_band.js
index b60a8ec..23a900f 100644
--- a/public/views/health_band.js
+++ b/public/views/health_band.js
@@ -2,6 +2,7 @@ import { el, mount } from '../dom.js';
import { api } from '../api.js';
import { littleblueAvatar } from '../components/littleblue_avatar.js';
import { serviceTile } from '../components/service_tile.js';
+import { isRemoteHost } from './service_url.js';
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
let host, timer, scanning = false;
@@ -38,13 +39,14 @@ async function load() {
if (!host) return;
try {
const groups = await api.get('/api/health/services');
+ const remote = isRemoteHost(location.hostname);
const sections = groups.map(g =>
el('div', { class: 'lb-section' },
el('div', { class: 'lb-group' },
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
el('span', { class: 'line' })),
- el('div', { class: 'tiles' }, g.services.map(serviceTile))));
+ el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
const disc = await discoveredSection();
mount(host,
el('div', { class: 'lbwrap' }, littleblueAvatar(),
diff --git a/public/views/service_url.js b/public/views/service_url.js
new file mode 100644
index 0000000..add9b4a
--- /dev/null
+++ b/public/views/service_url.js
@@ -0,0 +1,28 @@
+// 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 };
+}
diff --git a/server.js b/server.js
index f540352..547def8 100644
--- a/server.js
+++ b/server.js
@@ -13,7 +13,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
import { handleMcp } from './lib/mcp/http.js';
import httpProxy from 'http-proxy';
-const VERSION = '2.0.0-alpha.22';
+const VERSION = '2.0.0-alpha.23';
// Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
diff --git a/tests/api/health.test.js b/tests/api/health.test.js
index 325a185..30ab4f0 100644
--- a/tests/api/health.test.js
+++ b/tests/api/health.test.js
@@ -28,6 +28,18 @@ describe('health api (DB-backed registry)', () => {
expect(infra.services.find(s => s.id === 'gitea').status).toBe('ok');
});
+ it('GET /services includes external on every tile; POST round-trips it', async () => {
+ const res = await request(app).get('/api/health/services').set(ownerHeaders);
+ const all = res.body.flatMap(g => g.services);
+ expect(all.every(s => 'external' in s)).toBe(true); // key present (may be null)
+ expect(all.find(s => s.id === 'gitea').external).toBeNull(); // no domain set
+ await request(app).post('/api/health/services').set(ownerHeaders)
+ .send({ id: 'gramps', name: 'Gramps', category: 'infrastructure', url: 'http://192.168.1.99', external: 'https://gramps.hynesy.com' });
+ const res2 = await request(app).get('/api/health/services').set(ownerHeaders);
+ const gramps = res2.body.flatMap(g => g.services).find(s => s.id === 'gramps');
+ expect(gramps.external).toBe('https://gramps.hynesy.com');
+ });
+
it('POST /services adds a service that shows up in the band', async () => {
const create = await request(app).post('/api/health/services').set(ownerHeaders)
.send({ id: 'ollama', name: 'Ollama', category: 'agents', host: 'ct102', url: 'http://192.168.1.185:11434' });
diff --git a/tests/frontend/service_tile.test.js b/tests/frontend/service_tile.test.js
new file mode 100644
index 0000000..3660bd7
--- /dev/null
+++ b/tests/frontend/service_tile.test.js
@@ -0,0 +1,42 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { JSDOM } from 'jsdom';
+import { serviceTile } from '../../public/components/service_tile.js';
+
+// Provide a DOM via jsdom WITHOUT switching this file to the `jsdom` vitest
+// environment — a second environment makes vitest run a parallel worker pool that
+// collides with the DB-backed node tests on the shared test database. We set the
+// globals dom.js needs (document/Node) for this file only and tear them down after.
+beforeAll(() => {
+ const dom = new JSDOM('', { url: 'http://localhost/' });
+ global.window = dom.window;
+ global.document = dom.window.document;
+ global.Node = dom.window.Node;
+ global.location = dom.window.location; // safeHref() resolves against location.origin
+});
+afterAll(() => {
+ delete global.window; delete global.document; delete global.Node; delete global.location;
+});
+
+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);
+ 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(t.querySelectorAll('a').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();
+ });
+});
diff --git a/tests/frontend/service_url.test.js b/tests/frontend/service_url.test.js
new file mode 100644
index 0000000..251d9d8
--- /dev/null
+++ b/tests/frontend/service_url.test.js
@@ -0,0 +1,29 @@
+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 });
+ });
+});
diff --git a/tests/health/registry.test.js b/tests/health/registry.test.js
index 4bdf933..7499da0 100644
--- a/tests/health/registry.test.js
+++ b/tests/health/registry.test.js
@@ -30,4 +30,12 @@ describe('registry', () => {
expect(after.every(s => s.source === 'manual' && s.enabled)).toBe(true);
expect(await seedFromConfig()).toBe(0); // table not empty → no-op
});
+
+ it('persists and returns external on create/get/update', async () => {
+ const id = 'ext-test';
+ await services.create({ id, name: 'Ext', url: 'http://10.0.0.1', external: 'https://ext.example.com' });
+ expect((await services.get(id)).external).toBe('https://ext.example.com');
+ const upd = await services.update(id, { external: 'https://ext2.example.com' });
+ expect(upd.external).toBe('https://ext2.example.com');
+ });
});