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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
27
tests/frontend/service_tile.test.js
Normal file
27
tests/frontend/service_tile.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user