From 056e6a099ba29894898f541f2653a69e7add45a6 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 21:06:05 +1000 Subject: [PATCH] feat(devices): DB-backed devices band + discovered review/add/edit UI --- public/style.css | 8 +++ public/views/devices_band.js | 91 +++++++++++++++++++---------- tests/frontend/devices_band.test.js | 36 ++++++++++++ 3 files changed, 105 insertions(+), 30 deletions(-) create mode 100644 tests/frontend/devices_band.test.js diff --git a/public/style.css b/public/style.css index 6b6c60f..138005b 100644 --- a/public/style.css +++ b/public/style.css @@ -566,6 +566,14 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; } .dv-tile.flag { border-color: var(--bad); background: #1a1012; } .dv-tile.flag .dv-nm { color: var(--bad); } +.dv-tile.absent { opacity: .5; } +.dv-discovered { border: 1px solid var(--accent-dim); border-radius: 6px; padding: 10px 12px; margin: 10px 0; background: var(--accent-soft); } +.dv-disc-hd { font-family: var(--font-display); font-size: 12px; text-transform: uppercase; letter-spacing: .1em; color: var(--accent); margin-bottom: 8px; } +.dv-disc-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 5px 0; } +.dv-disc-row .dv-edit-name { flex: 1 1 120px; } +.dv-disc-row .dv-add { background: var(--accent-dim); color: var(--text); border: 1px solid var(--accent); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-family: var(--font-ui); font-size: 12px; } +.dv-disc-row .dv-add:hover { background: var(--accent); color: var(--bg); } +.dv-disc-row .ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); border-radius: 3px; padding: 4px 10px; cursor: pointer; font-size: 12px; } /* ===== Discovered services + scan (Plan: DB-backed registry) ===== */ .lb-scan { background: var(--accent-soft); border: 1px solid var(--accent-dim); color: var(--accent); diff --git a/public/views/devices_band.js b/public/views/devices_band.js index 6a046cf..79d0212 100644 --- a/public/views/devices_band.js +++ b/public/views/devices_band.js @@ -1,36 +1,67 @@ -// Network Devices band — IoT / personal / unknown LAN devices, kept SEPARATE -// from Little Blue's homelab-service health band. Read-only, static source -// (public/devices.json), no health probing. Live discovery comes later. -import { el, mount } from '../dom.js'; +// 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'; let host; +const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; + +function tile(d) { + return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') }, + 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' : ''))); +} + +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); +} + async function load() { if (!host) return; - try { - const res = await fetch('/devices.json'); - const data = await res.json(); - 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(d => - el('div', { class: 'dv-tile' + (d.flag ? ' flag' : '') }, - el('span', { class: 'dv-nm' }, d.name), - el('span', { class: 'dv-ip' }, d.ip), - d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null, - el('span', { class: 'dv-vendor' }, d.vendor || '')))))); - mount(host, - el('div', { class: 'dv-hd' }, - el('div', { class: 'dv-title' }, 'Network · Devices'), - el('span', { class: 'dv-count' }, `${total} on the LAN`)), - el('div', { class: 'dv-note' }, data.note || ''), - sections); - } catch { - mount(host, el('span', { class: 'muted' }, 'Device list unavailable')); - } + 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; + + 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` : ''}`)), + ...sections, + discPanel); } -export function renderDevicesBand(el_) { host = el_; load(); } + +export function renderDevicesBand(root) { host = root; return load(); } export function stopDevicesBand() { host = null; } diff --git a/tests/frontend/devices_band.test.js b/tests/frontend/devices_band.test.js new file mode 100644 index 0000000..3f6fdec --- /dev/null +++ b/tests/frontend/devices_band.test.js @@ -0,0 +1,36 @@ +// tests/frontend/devices_band.test.js +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { JSDOM } from 'jsdom'; + +vi.mock('../../public/api.js', () => ({ + api: { + get: vi.fn(async (p) => { + if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [ + { mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', vendor: 'Netgear', randomized: false, present: true } ] } ] }; + if (p === '/api/devices/discovered') return [ + { mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ]; + return {}; + }), + patch: vi.fn(async () => ({})) + } +})); + +let renderDevicesBand; +beforeAll(async () => { + const dom = new JSDOM('
', { url: 'http://localhost/' }); + global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; + ({ renderDevicesBand } = await import('../../public/views/devices_band.js')); +}); +afterAll(() => { delete global.window; delete global.document; delete global.Node; }); + +describe('devices band', () => { + it('renders known devices from the API with MAC, and a discovered count', async () => { + const host = document.getElementById('h'); + await renderDevicesBand(host); + await new Promise(r => setTimeout(r, 0)); + expect(host.textContent).toContain('Orbi Satellite'); + expect(host.querySelector('.dv-mac').textContent).toBe('bc:a5:11:3e:06:88'); + expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present + expect(host.textContent).toMatch(/Discovered/i); + }); +});