Files
Void-Homelab/public/views/devices_band.js
2026-06-09 08:56:21 +10:00

153 lines
7.3 KiB
JavaScript

// Network Devices band — DB-backed (GET /api/devices). Shows IP+MAC+vendor,
// a randomized-MAC badge, and an owner "Discovered" review panel to name/promote
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
import { el, mount, clear } from '../dom.js';
import { api } from '../api.js';
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
import { iconPicker } from './icon_picker.js';
let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
function tile(d) {
const t = el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') });
function view() {
clear(t);
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
edit.onclick = editMode;
const ref = d.icon || autoDefaultIcon(d.grp);
const src = resolveIcon(ref);
const img = el('img', { class: 'dv-icon', src, alt: '' });
img.onerror = () => {
if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; }
img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase()));
};
const seen = d.present === false && d.last_seen
? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null;
mount(t, img,
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
seen,
d.mac ? edit : null);
}
function editMode() {
clear(t);
let chosenIcon = d.icon || null;
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
grpS.value = d.grp || 'Flagged';
const pickerWrap = el('div', { class: 'dv-picker-wrap' });
pickerWrap.style.display = 'none';
const iconBtn = el('button', { class: 'ghost' }, 'Icon');
iconBtn.onclick = () => {
if (pickerWrap.style.display === 'none') {
clear(pickerWrap);
pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; }));
pickerWrap.style.display = 'block';
} else pickerWrap.style.display = 'none';
};
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => {
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon });
load();
};
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = view;
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
}
view();
return t;
}
function discoveredRow(d, onDone) {
const nameI = el('input', { class: 'dv-edit-name', placeholder: d.vendor || 'name', value: d.name || '' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
const add = el('button', { class: 'dv-add' }, 'Add');
add.onclick = async () => {
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, status: 'known', flagged: false });
onDone();
};
const ignore = el('button', { class: 'ghost dv-ignore' }, 'Ignore');
ignore.onclick = async () => { await api.patch('/api/devices/' + d.mac, { status: 'ignored' }); onDone(); };
return el('div', { class: 'dv-disc-row' },
el('span', { class: 'dv-ip' }, d.ip || ''),
el('span', { class: 'dv-mac' }, d.mac + (d.randomized ? ' · randomized' : '')),
el('span', { class: 'dv-vendor' }, d.vendor || ''),
nameI, grpS, add, ignore);
}
// Manual add form — for offline devices (MAC required; IP optional). The MAC
// field auto-inserts the colons as you type.
function manualAddForm() {
const macI = el('input', { class: 'dv-edit-name', placeholder: 'aa:bb:cc:dd:ee:ff' });
macI.oninput = () => {
const v = macI.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 12).toLowerCase();
macI.value = v.match(/.{1,2}/g)?.join(':') ?? v;
};
const ipI = el('input', { class: 'dv-edit-name', placeholder: 'IP (optional)' });
const nameI = el('input', { class: 'dv-edit-name', placeholder: 'name (optional)' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
const add = el('button', { class: 'dv-add' }, 'Add');
add.onclick = async () => {
const mac = macI.value.trim().toLowerCase();
if (!/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(mac)) { err.textContent = 'MAC must look like aa:bb:cc:dd:ee:ff'; return; }
const ip = ipI.value.trim();
if (ip && !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) { err.textContent = 'IP must look like 192.168.1.x'; return; }
try { await api.post('/api/devices', { mac, ip: ip || undefined, name: nameI.value.trim() || undefined, grp: grpS.value }); load(); }
catch { err.textContent = 'add failed'; }
};
return el('div', { class: 'dv-addform' }, macI, ipI, nameI, grpS, add, err);
}
async function load() {
if (!host) return;
let data, discovered = [];
try { data = await api.get('/api/devices'); } catch { mount(host, el('div', { class: 'dv-note' }, 'Devices unavailable')); return; }
try { discovered = await api.get('/api/devices/discovered'); } catch { /* owner-only; ignore for non-owner */ }
const total = data.groups.reduce((n, g) => n + g.devices.length, 0);
const sections = data.groups.map(g =>
el('div', { class: 'dv-section' },
el('div', { class: 'dv-group' },
el('span', { class: 'gname' }, g.name),
el('span', { class: 'gcount' }, String(g.devices.length)),
el('span', { class: 'line' })),
el('div', { class: 'dv-tiles' }, g.devices.map(tile))));
const discPanel = discovered.length
? el('div', { class: 'dv-discovered' },
el('div', { class: 'dv-disc-hd' }, `Discovered · ${discovered.length} awaiting review`),
...discovered.map(d => discoveredRow(d, load)))
: null;
const addForm = manualAddForm();
addForm.style.display = 'none';
const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Manual Add');
addToggle.onclick = () => { addForm.style.display = addForm.style.display === 'none' ? 'flex' : 'none'; };
const scanBtn = el('button', { class: 'ghost dv-scanbtn' }, 'Scan Now');
scanBtn.onclick = async () => {
scanBtn.textContent = 'Scanning…'; scanBtn.disabled = true;
try { await api.post('/api/devices/scan'); } catch { /* ignore */ }
load();
};
clear(host);
mount(host,
el('div', { class: 'dv-hd' },
el('div', { class: 'dv-title' }, 'Network · Devices'),
el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`),
addToggle, scanBtn),
addForm,
...sections,
discPanel);
}
export function renderDevicesBand(root) { host = root; return load(); }
export function stopDevicesBand() { host = null; }