feat(health): tile opens local/remote URL with one-click alt + LAN-only marker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 00:57:48 +10:00
parent a3662f6ff2
commit d91e5e75c8
3 changed files with 55 additions and 4 deletions

View File

@@ -1,11 +1,33 @@
import { el, safeHref } from '../dom.js'; 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 }); 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())); 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, img,
el('div', { class: 'tile-main' }, el('div', { class: 'tile-main' },
el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name), el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name),
el('div', { class: 'tile-host' }, s.host || '')), 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

@@ -2,6 +2,7 @@ import { el, mount } from '../dom.js';
import { api } from '../api.js'; import { api } from '../api.js';
import { littleblueAvatar } from '../components/littleblue_avatar.js'; import { littleblueAvatar } from '../components/littleblue_avatar.js';
import { serviceTile } from '../components/service_tile.js'; import { serviceTile } from '../components/service_tile.js';
import { isRemoteHost } from './service_url.js';
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' }; const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
let host, timer, scanning = false; let host, timer, scanning = false;
@@ -38,13 +39,14 @@ async function load() {
if (!host) return; if (!host) return;
try { try {
const groups = await api.get('/api/health/services'); const groups = await api.get('/api/health/services');
const remote = isRemoteHost(location.hostname);
const sections = groups.map(g => const sections = groups.map(g =>
el('div', { class: 'lb-section' }, el('div', { class: 'lb-section' },
el('div', { class: 'lb-group' }, el('div', { class: 'lb-group' },
el('span', { class: 'gname' }, TITLE[g.category] || g.category), el('span', { class: 'gname' }, TITLE[g.category] || g.category),
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`), el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
el('span', { class: 'line' })), 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(); const disc = await discoveredSection();
mount(host, mount(host,
el('div', { class: 'lbwrap' }, littleblueAvatar(), el('div', { class: 'lbwrap' }, littleblueAvatar(),

View File

@@ -0,0 +1,27 @@
// @vitest-environment jsdom
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);
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();
});
});