diff --git a/public/components/service_tile.js b/public/components/service_tile.js index 8525ebe..9e71c17 100644 --- a/public/components/service_tile.js +++ b/public/components/service_tile.js @@ -1,11 +1,33 @@ import { el, safeHref } from '../dom.js'; -export function serviceTile(s) { +import { isRemoteHost, pickServiceUrls } from '../views/service_url.js'; + +// `remote` is injected by the caller (defaults to detecting from the current host) so +// the component stays unit-testable without stubbing window.location. +export function serviceTile(s, remote = isRemoteHost(location.hostname)) { + const { primary, alt, lanOnly } = pickServiceUrls(s, remote); + const img = el('img', { class: 'tile-icon', loading: 'lazy', src: `/api/icons/${s.icon}.png`, alt: s.name }); img.onerror = () => img.replaceWith(el('div', { class: 'tile-icon-fb' }, (s.name[0] || '?').toUpperCase())); - return el('a', { class: `tile status-${s.status}`, href: safeHref(s.url), target: '_blank', rel: 'noreferrer' }, + + // Root is a div so we can host two sibling s (a stretched primary link + a small + // alt) without nesting anchors (invalid HTML). + const tile = el('div', { class: `tile status-${s.status}${lanOnly ? ' lan-only' : ''}` }, img, el('div', { class: 'tile-main' }, el('div', { class: 'tile-nm' }, el('span', { class: 'dot' }), s.name), el('div', { class: 'tile-host' }, s.host || '')), - el('span', { class: 'tile-go' }, 'open ↗')); + el('span', { class: 'tile-go' }, 'open ↗'), + // Stretched primary link covers the whole tile (see .tile-link in style.css). + el('a', { class: 'tile-link', href: safeHref(primary), target: '_blank', rel: 'noreferrer', + 'aria-label': `Open ${s.name}` })); + + if (lanOnly) tile.appendChild(el('span', { class: 'tile-lan', title: 'Not reachable remotely' }, 'LAN-only')); + + if (alt) { + tile.appendChild(el('a', { + class: 'tile-alt', href: safeHref(alt), target: '_blank', rel: 'noreferrer', + title: remote ? 'Open via LAN' : 'Open via domain', + }, '⇄')); + } + return tile; } diff --git a/public/views/health_band.js b/public/views/health_band.js index b60a8ec..23a900f 100644 --- a/public/views/health_band.js +++ b/public/views/health_band.js @@ -2,6 +2,7 @@ import { el, mount } from '../dom.js'; import { api } from '../api.js'; import { littleblueAvatar } from '../components/littleblue_avatar.js'; import { serviceTile } from '../components/service_tile.js'; +import { isRemoteHost } from './service_url.js'; const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' }; let host, timer, scanning = false; @@ -38,13 +39,14 @@ async function load() { if (!host) return; try { const groups = await api.get('/api/health/services'); + const remote = isRemoteHost(location.hostname); const sections = groups.map(g => el('div', { class: 'lb-section' }, el('div', { class: 'lb-group' }, el('span', { class: 'gname' }, TITLE[g.category] || g.category), el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`), el('span', { class: 'line' })), - el('div', { class: 'tiles' }, g.services.map(serviceTile)))); + el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote))))); const disc = await discoveredSection(); mount(host, el('div', { class: 'lbwrap' }, littleblueAvatar(), diff --git a/tests/frontend/service_tile.test.js b/tests/frontend/service_tile.test.js new file mode 100644 index 0000000..4c3ebf3 --- /dev/null +++ b/tests/frontend/service_tile.test.js @@ -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(); + }); +});