Merge: local/remote-aware service tiles (2.0.0-alpha.23)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 11:05:40 +10:00
17 changed files with 832 additions and 25 deletions

View File

@@ -3,6 +3,10 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 2.0.0-alpha.23 — Local/remote-aware service tiles
- **Optional `external` URL per service** (`migration 022`, `config/services.json`, repo + `/api/health/services` payload + `svcBody`): Little Blue health-band tiles previously linked to the single LAN `url`, so they opened dead private IPs when browsing remotely (e.g. Gramps `http://192.168.1.99`). Migration adds the column and **backfills** curated domains by id (the live instance is already seeded, so a column-add alone wouldn't populate them); also normalises `jellyfin`/`chaptarr` (which stored a domain in `url`) to LAN `url` + `external`.
- **Context-based tile target + one-click alt** (`public/views/service_url.js`, `public/components/service_tile.js`, `public/views/health_band.js`): the tile picks its primary URL from `location.hostname` — public host (e.g. `void.hynesy.com`) opens the domain, private IP/localhost/.local opens the LAN address — and always offers a `⇄` alt to the *other* URL (a reliable manual fallback; an auto-probe can't work because an HTTPS dashboard is blocked from probing `http://` LAN IPs by mixed-content). Services with no `external` are dimmed with a "LAN-only" badge when remote. Tile root is now a `div` with a stretched primary `<a>` + sibling alt `<a>` (no nested anchors). Health checker unchanged (still probes LAN `url` from CT 311).
## 2.0.0-alpha.22 — Kill the stale Void 1 service worker
- **Self-unregistering `/sw.js` tombstone** (`public/sw.js`): Void 1 registered a caching service worker at this origin; it persisted across the cutover and served stale assets to returning visitors (immune to hard reload, since an active SW sits in front of the browser cache). Void 2 ships no SW, so the old one was never replaced. This tombstone is picked up by the browser's SW update check, then clears all caches, unregisters itself, and reloads open tabs — self-healing every device that ever ran Void 1. Root cause of "the Wiki still shows projects/tasks and isn't sectioned" despite the docs-mode + ordering code being correctly deployed.

View File

@@ -1,13 +1,13 @@
[
{ "id": "void-server", "name": "Void 2.0", "category": "agents", "host": "ct311", "url": "http://192.168.1.216:3000", "icon": "void", "check": { "type": "http", "path": "/health" } },
{ "id": "void-server", "name": "Void 2.0", "category": "agents", "host": "ct311", "url": "http://192.168.1.216:3000", "external": "https://void.hynesy.com", "icon": "void", "check": { "type": "http", "path": "/health" } },
{ "id": "ollama", "name": "Ollama", "category": "agents", "host": "ct102", "url": "http://192.168.1.185:11434", "icon": "ollama" },
{ "id": "openwebui", "name": "Open WebUI", "category": "agents", "host": "192.168.1.231", "url": "http://192.168.1.231:8080", "icon": "open-webui" },
{ "id": "openclaw", "name": "OpenClaw", "category": "agents", "host": "vm200 · .183", "url": "http://192.168.1.183:22", "icon": "", "check": { "type": "tcp" } },
{ "id": "gitea", "name": "Gitea", "category": "infrastructure", "host": "ct105", "url": "http://192.168.1.223:3000", "icon": "gitea" },
{ "id": "pihole", "name": "Pi-hole", "category": "infrastructure", "host": "ct106", "url": "http://192.168.1.140/admin", "icon": "pi-hole" },
{ "id": "bookstack", "name": "BookStack", "category": "infrastructure", "host": "ct104", "url": "http://192.168.1.213:6875", "icon": "bookstack" },
{ "id": "gramps", "name": "Gramps Web", "category": "infrastructure", "host": "ct109", "url": "http://192.168.1.99", "icon": "gramps" },
{ "id": "bookstack", "name": "BookStack", "category": "infrastructure", "host": "ct104", "url": "http://192.168.1.213:6875", "external": "https://bookstack.hynesy.com", "icon": "bookstack" },
{ "id": "gramps", "name": "Gramps Web", "category": "infrastructure", "host": "ct109", "url": "http://192.168.1.99", "external": "https://gramps.hynesy.com", "icon": "gramps" },
{ "id": "scanopy", "name": "Scanopy", "category": "infrastructure", "host": "ct100", "url": "http://192.168.1.230:60072", "icon": "scanopy" },
{ "id": "homelab", "name": "Homelable", "category": "infrastructure", "host": "ct100", "url": "http://192.168.1.230:3000", "icon": "" },
{ "id": "obd2", "name": "OBD2", "category": "infrastructure", "host": "ct .28", "url": "http://192.168.1.28:8384", "icon": "" },
@@ -15,13 +15,13 @@
{ "id": "pve-z", "name": "Proxmox · z", "category": "infrastructure", "host": "z", "url": "https://192.168.1.124:8006", "icon": "proxmox", "check": { "type": "tcp" } },
{ "id": "pve-z3", "name": "Proxmox · Z3", "category": "infrastructure", "host": "z3", "url": "https://192.168.1.125:8006", "icon": "proxmox", "check": { "type": "tcp" } },
{ "id": "plex", "name": "Plex", "category": "media", "host": "ct100", "url": "http://192.168.1.230:32400/web", "icon": "plex" },
{ "id": "jellyfin", "name": "Jellyfin", "category": "media", "host": "ct100", "url": "https://jellyfin.hynesy.com", "icon": "jellyfin" },
{ "id": "tdarr", "name": "Tdarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8265", "icon": "tdarr" },
{ "id": "sonarr", "name": "Sonarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8989", "icon": "sonarr" },
{ "id": "radarr", "name": "Radarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:7878", "icon": "radarr" },
{ "id": "plex", "name": "Plex", "category": "media", "host": "ct100", "url": "http://192.168.1.230:32400/web", "external": "https://plex.hynesy.com", "icon": "plex" },
{ "id": "jellyfin", "name": "Jellyfin", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8096", "external": "https://jellyfin.hynesy.com", "icon": "jellyfin" },
{ "id": "tdarr", "name": "Tdarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8265", "external": "https://tdarr.hynesy.com", "icon": "tdarr" },
{ "id": "sonarr", "name": "Sonarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8989", "external": "https://sonarr.hynesy.com", "icon": "sonarr" },
{ "id": "radarr", "name": "Radarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:7878", "external": "https://radarr.hynesy.com", "icon": "radarr" },
{ "id": "qbittorrent", "name": "qBittorrent", "category": "media", "host": "win10", "url": "http://192.168.1.230:8080", "icon": "qbittorrent" },
{ "id": "chaptarr", "name": "Chaptarr", "category": "media", "host": "ct100", "url": "https://chaptarr.hynesy.com", "icon": "readarr" },
{ "id": "chaptarr", "name": "Chaptarr", "category": "media", "host": "ct100", "url": "http://192.168.1.230:8789", "external": "https://chaptarr.hynesy.com", "icon": "readarr" },
{ "id": "void1", "name": "The Void 1.x", "category": "other", "host": "ct301", "url": "http://192.168.1.11:2424", "icon": "void" },
{ "id": "farm-timelapse", "name": "Farm Timelapse", "category": "other", "host": "192.168.1.108", "url": "http://192.168.1.108:8000", "icon": "" },

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

View File

@@ -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()
});

View File

@@ -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';

View File

@@ -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}`,

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.0.0-alpha.22",
"version": "2.0.0-alpha.23",
"type": "module",
"private": true,
"scripts": {

View File

@@ -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 <a>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;
}

View File

@@ -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; }

View File

@@ -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(),

View File

@@ -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 };
}

View File

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

View File

@@ -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' });

View File

@@ -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('<!doctype html><html><body></body></html>', { 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();
});
});

View File

@@ -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 });
});
});

View File

@@ -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');
});
});