docs(health): spec + plan for local/remote-aware service tiles
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
512
docs/superpowers/plans/2026-06-08-service-tile-local-remote.md
Normal file
512
docs/superpowers/plans/2026-06-08-service-tile-local-remote.md
Normal 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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user