From 26463b5eb6e0622c64c278db8e40644fc6371220 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 23:58:19 +1000 Subject: [PATCH] =?UTF-8?q?feat(devices):=20Scan=20Now=20+=20Manual=20Add?= =?UTF-8?q?=20(IP=20option,=20MAC=20colon-mask)=20=E2=86=92=202.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Scan Now' triggers POST /api/devices/scan from the band header. '+ Add by MAC' renamed '+ Manual Add' with an optional IP field (addBody/addManual accept ip) and a MAC input that auto-inserts colons as you type. Frontend test 4/4; DB-backed api/repo tests written (run with the suite — skipped locally to avoid colliding with a concurrent test run on void_test). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ lib/api/routes/devices.js | 1 + lib/db/repos/lan_devices.js | 9 +++++---- package.json | 2 +- public/style.css | 2 ++ public/views/devices_band.js | 24 +++++++++++++++++++----- server.js | 2 +- tests/api/devices.test.js | 6 ++++-- tests/frontend/devices_band.test.js | 21 +++++++++++++++++---- 9 files changed, 54 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2afcaae..9e4493e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## 2.1.4 — Devices band: Scan Now + richer Manual Add +- **"Scan Now" button** in the Network·Devices header — triggers the scheduled scan on demand (`POST /api/devices/scan`) and refreshes the band. +- **"+ Add by MAC" → "+ Manual Add"**, now with an optional **IP** field (`POST /api/devices` + `lan_devices.addManual` accept `ip`), and the **MAC field auto-inserts the colons** as you type. + ## 2.1.3 — Manually add a device by MAC - **"+ Add by MAC" in the Network·Devices band** (`POST /api/devices`, `lan_devices.addManual`, `devices_band.js`): pre-register an **offline** device by typing its MAC (+ optional name/group). Lands as `status='known'`, `present=false`; it gets enriched (IP/vendor/present) automatically the next time it's seen by the scan. Idempotent. diff --git a/lib/api/routes/devices.js b/lib/api/routes/devices.js index f4169c7..9580901 100644 --- a/lib/api/routes/devices.js +++ b/lib/api/routes/devices.js @@ -70,6 +70,7 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody const addBody = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i), + ip: z.string().regex(/^\d{1,3}(\.\d{1,3}){3}$/).optional(), name: z.string().max(120).optional(), grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(), vendor: z.string().max(120).optional() diff --git a/lib/db/repos/lan_devices.js b/lib/db/repos/lan_devices.js index 1af6e4a..626dacd 100644 --- a/lib/db/repos/lan_devices.js +++ b/lib/db/repos/lan_devices.js @@ -21,16 +21,17 @@ export async function get(mac) { // Manually add a device by MAC (e.g. an offline device whose MAC you know). Lands // as status='known', present=false. Idempotent — re-adding updates name/grp/vendor. -export async function addManual({ mac, name = null, grp = 'Flagged', vendor = null, randomized = false }) { +export async function addManual({ mac, ip = null, name = null, grp = 'Flagged', vendor = null, randomized = false }) { const { rows: [r] } = await pool.query( - `INSERT INTO lan_devices (mac, name, grp, vendor, randomized, status, present, first_seen, last_seen) - VALUES ($1,$2,$3,$4,$5,'known',false,now(),now()) + `INSERT INTO lan_devices (mac, ip, name, grp, vendor, randomized, status, present, first_seen, last_seen) + VALUES ($1,$2,$3,$4,$5,$6,'known',false,now(),now()) ON CONFLICT (mac) DO UPDATE SET + ip = COALESCE(NULLIF(EXCLUDED.ip,''), lan_devices.ip), name = EXCLUDED.name, grp = EXCLUDED.grp, vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor), status = 'known' RETURNING ${COLS}`, - [mac, name, grp, vendor, !!randomized]); + [mac, ip, name, grp, vendor, !!randomized]); return r; } diff --git a/package.json b/package.json index 5b270db..1a4c317 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.1.3", + "version": "2.1.4", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index 57b202c..3051ca6 100644 --- a/public/style.css +++ b/public/style.css @@ -570,6 +570,8 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-tile .dv-edit-name, .dv-tile .dv-edit-grp { margin: 2px 0; width: 100%; } .dv-tile .dv-add, .dv-tile .dv-ignore, .dv-tile .ghost { margin-top: 4px; margin-right: 4px; font-size: 11px; padding: 2px 8px; } .dv-addtoggle { margin-left: auto; font-size: 11px; padding: 2px 8px; white-space: nowrap; } +.dv-scanbtn { font-size: 11px; padding: 2px 8px; white-space: nowrap; margin-left: 6px; } +.dv-scanbtn:disabled { opacity: .6; cursor: default; } .dv-addform { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; padding: 8px 10px; border: 1px solid var(--accent-dim); border-radius: 6px; background: var(--accent-soft); } .dv-addform .dv-edit-name { flex: 1 1 9rem; } .dv-tile .dv-vendor { font-family: var(--font-ui); font-size: 11px; color: var(--muted); opacity: .7; } diff --git a/public/views/devices_band.js b/public/views/devices_band.js index a7c87ba..d73a7a0 100644 --- a/public/views/devices_band.js +++ b/public/views/devices_band.js @@ -55,9 +55,15 @@ function discoveredRow(d, onDone) { nameI, grpS, add, ignore); } -// Manual "add by MAC" form — for offline devices whose MAC you know. +// 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' } }, ''); @@ -65,10 +71,12 @@ function manualAddForm() { 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; } - try { await api.post('/api/devices', { mac, name: nameI.value.trim() || undefined, grp: grpS.value }); load(); } + 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, nameI, grpS, add, err); + return el('div', { class: 'dv-addform' }, macI, ipI, nameI, grpS, add, err); } async function load() { @@ -94,15 +102,21 @@ async function load() { const addForm = manualAddForm(); addForm.style.display = 'none'; - const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Add by MAC'); + 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), + addToggle, scanBtn), addForm, ...sections, discPanel); diff --git a/server.js b/server.js index b68a316..1e88bac 100644 --- a/server.js +++ b/server.js @@ -14,7 +14,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js'; import { handleMcp } from './lib/mcp/http.js'; import httpProxy from 'http-proxy'; -const VERSION = '2.1.3'; +const VERSION = '2.1.4'; // Proxy /terminal (+ its WebSocket) to ttyd on CT 300, so the embedded terminal // works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the diff --git a/tests/api/devices.test.js b/tests/api/devices.test.js index a1ca115..d1e27bf 100644 --- a/tests/api/devices.test.js +++ b/tests/api/devices.test.js @@ -41,16 +41,18 @@ describe('/api/devices', () => { it('POST / manually adds an offline device by MAC (owner, lowercased, status=known, absent)', async () => { expect((await request(app).post('/api/devices').send({ mac: 'aa:bb:cc:dd:ee:ff' })).status).toBe(401); - const res = await owner(request(app).post('/api/devices')).send({ mac: 'AA:BB:CC:DD:EE:FF', name: 'Garage door', grp: 'Smart Home' }); + const res = await owner(request(app).post('/api/devices')).send({ mac: 'AA:BB:CC:DD:EE:FF', ip: '192.168.1.77', name: 'Garage door', grp: 'Smart Home' }); expect(res.status).toBe(201); expect(res.body.mac).toBe('aa:bb:cc:dd:ee:ff'); + expect(res.body.ip).toBe('192.168.1.77'); expect(res.body.status).toBe('known'); expect(res.body.present).toBe(false); const band = await request(app).get('/api/devices'); expect(band.body.groups.find(g => g.name === 'Smart Home').devices.some(d => d.name === 'Garage door')).toBe(true); }); - it('POST / rejects a bad MAC', async () => { + it('POST / rejects a bad MAC and a bad IP', async () => { expect((await owner(request(app).post('/api/devices')).send({ mac: 'nope' })).status).toBe(400); + expect((await owner(request(app).post('/api/devices')).send({ mac: 'aa:bb:cc:dd:ee:ff', ip: 'not-an-ip' })).status).toBe(400); }); }); diff --git a/tests/frontend/devices_band.test.js b/tests/frontend/devices_band.test.js index afdb8ab..9c5c62b 100644 --- a/tests/frontend/devices_band.test.js +++ b/tests/frontend/devices_band.test.js @@ -53,15 +53,28 @@ describe('devices band', () => { expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' })); }); - it('manual add-by-MAC reveals a form and POSTs the new device', async () => { + it('Manual Add reveals a form (with IP) and POSTs the new device; MAC field auto-inserts colons', async () => { const host = document.getElementById('h'); await renderDevicesBand(host); await new Promise(r => setTimeout(r, 0)); + expect(host.querySelector('.dv-addtoggle').textContent).toBe('+ Manual Add'); host.querySelector('.dv-addtoggle').click(); // reveal the form - const macI = host.querySelector('.dv-addform .dv-edit-name'); - macI.value = 'aa:bb:cc:dd:ee:ff'; + const [macI, ipI] = host.querySelectorAll('.dv-addform .dv-edit-name'); + macI.value = 'aabbccddeeff'; + macI.dispatchEvent(new window.Event('input')); // colon-mask + expect(macI.value).toBe('aa:bb:cc:dd:ee:ff'); + ipI.value = '192.168.1.50'; host.querySelector('.dv-addform .dv-add').click(); await new Promise(r => setTimeout(r, 0)); - expect(api.post).toHaveBeenCalledWith('/api/devices', expect.objectContaining({ mac: 'aa:bb:cc:dd:ee:ff' })); + expect(api.post).toHaveBeenCalledWith('/api/devices', expect.objectContaining({ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.50' })); + }); + + it('Scan Now triggers the scheduled scan', async () => { + const host = document.getElementById('h'); + await renderDevicesBand(host); + await new Promise(r => setTimeout(r, 0)); + host.querySelector('.dv-scanbtn').click(); + await new Promise(r => setTimeout(r, 0)); + expect(api.post).toHaveBeenCalledWith('/api/devices/scan'); }); });