Cross-references each candidate host IP with lan_devices (known) so a tile shows e.g. 'H Tower' instead of '192.168.1.15:32400'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
4.6 KiB
JavaScript
94 lines
4.6 KiB
JavaScript
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' };
|
|
const CATS = ['agents', 'infrastructure', 'media', 'other'];
|
|
let host, timer, scanning = false;
|
|
|
|
async function promote(id) {
|
|
try { await api.patch('/api/health/services/' + id, { enabled: true }); load(); } catch { /* */ }
|
|
}
|
|
function scan() {
|
|
if (scanning) return;
|
|
scanning = true; load(); // reflect "Scanning…"
|
|
api.post('/api/health/discover', {}).catch(() => { /* */ });
|
|
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
|
}
|
|
|
|
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
|
|
function editForm(s) {
|
|
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
|
|
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
|
|
catS.value = s.category || 'other';
|
|
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
|
|
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
|
|
const save = el('button', { class: 'dv-add' }, 'Save');
|
|
save.onclick = async () => {
|
|
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
|
|
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
|
|
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
|
|
};
|
|
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
|
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
|
|
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
|
cancel.onclick = load;
|
|
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
|
|
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
|
|
}
|
|
|
|
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
|
|
function tileWithEdit(s, remote) {
|
|
const wrap = el('div', { class: 'lb-tile-wrap' });
|
|
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
|
|
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
|
|
mount(wrap, serviceTile(s, remote), edit);
|
|
return wrap;
|
|
}
|
|
|
|
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
|
async function discoveredSection() {
|
|
let cand;
|
|
try { cand = await api.get('/api/health/services/discovered'); } catch { return null; }
|
|
if (!cand || !cand.length) return null;
|
|
return el('div', { class: 'lb-section' },
|
|
el('div', { class: 'lb-group' },
|
|
el('span', { class: 'gname' }, 'Discovered'),
|
|
el('span', { class: 'gcount' }, `${cand.length} new`),
|
|
el('span', { class: 'line' })),
|
|
el('div', { class: 'tiles' }, cand.map(c =>
|
|
el('div', { class: 'tile disc' },
|
|
el('div', { class: 'tile-main' },
|
|
el('div', { class: 'tile-nm' }, c.device || c.name),
|
|
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
|
|
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
|
}
|
|
|
|
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(s => tileWithEdit(s, remote)))));
|
|
const disc = await discoveredSection();
|
|
mount(host,
|
|
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
|
el('div', { style: { flex: 1 } },
|
|
el('div', { class: 'lb-name' }, 'Little Blue'),
|
|
el('div', { class: 'lb-sub' }, 'Health & Uptime of the lab')),
|
|
el('button', { class: 'lb-scan', title: 'Scan the LAN for services', onclick: scan }, scanning ? 'Scanning…' : 'Scan')),
|
|
sections,
|
|
disc);
|
|
} catch { mount(host, el('span', { class: 'muted' }, 'Health band unavailable')); }
|
|
}
|
|
export function renderHealthBand(el_) { host = el_; load(); timer = setInterval(load, 60000); }
|
|
export function stopHealthBand() { clearInterval(timer); host = null; }
|