From 88ef5786ee8fb0b8a29b944ed4e4d050460cf80c Mon Sep 17 00:00:00 2001 From: root Date: Mon, 8 Jun 2026 23:12:47 +1000 Subject: [PATCH] =?UTF-8?q?feat(devices):=20manually=20add=20a=20device=20?= =?UTF-8?q?by=20MAC=20(offline=20pre-register)=20=E2=86=92=202.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '+ Add by MAC' in the band header → POST /api/devices → lan_devices.addManual (status=known, present=false; enriched on next scan). Repo + API + frontend tests. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +++ lib/api/routes/devices.js | 14 ++++++++++++++ lib/db/repos/lan_devices.js | 15 +++++++++++++++ package.json | 2 +- public/style.css | 3 +++ public/views/devices_band.js | 25 ++++++++++++++++++++++++- server.js | 2 +- tests/api/devices.test.js | 15 +++++++++++++++ tests/frontend/devices_band.test.js | 13 +++++++++++++ 9 files changed, 89 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d6c0a..2afcaae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to Void 2.0 are documented here. Format: [Keep a Changelog](https://keepachangelog.com). +## 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. + ## 2.1.2 — Edit known network devices - **Edit devices in the Network·Devices band** (`public/views/devices_band.js`): known tiles get a ✎ edit affordance — rename, re-group, or delete a device (PATCH/DELETE `/api/devices/:mac`, which already existed). Previously a device could only be named when first promoted from Discovered. diff --git a/lib/api/routes/devices.js b/lib/api/routes/devices.js index beee87a..f4169c7 100644 --- a/lib/api/routes/devices.js +++ b/lib/api/routes/devices.js @@ -4,6 +4,7 @@ import { asyncWrap, errorMiddleware } from '../errors.js'; import { requireOwner } from '../cap.js'; import { validate } from '../validate.js'; import * as devices from '../../db/repos/lan_devices.js'; +import { isRandomizedMac } from '../../infra/scan.js'; import * as agents from '../../db/repos/agents.js'; import { timingSafeStrEqual } from '../../auth/safe_compare.js'; import { accessOwnerEmail } from '../../auth/cf_access.js'; @@ -67,6 +68,19 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody res.json(updated); })); +const addBody = z.object({ + mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i), + name: z.string().max(120).optional(), + grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(), + vendor: z.string().max(120).optional() +}); + +// POST /devices — manually add a device by MAC (e.g. an offline device) (owner). +router.post('/', requireOwner, validate({ body: addBody }), asyncWrap(async (req, res) => { + const mac = req.body.mac.toLowerCase(); + res.status(201).json(await devices.addManual({ ...req.body, mac, randomized: isRandomizedMac(mac) })); +})); + // DELETE /devices/:mac (owner). router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => { if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } }); diff --git a/lib/db/repos/lan_devices.js b/lib/db/repos/lan_devices.js index d0ea987..1af6e4a 100644 --- a/lib/db/repos/lan_devices.js +++ b/lib/db/repos/lan_devices.js @@ -19,6 +19,21 @@ export async function get(mac) { return r || null; } +// 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 }) { + 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()) + ON CONFLICT (mac) DO UPDATE SET + name = EXCLUDED.name, grp = EXCLUDED.grp, + vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor), + status = 'known' + RETURNING ${COLS}`, + [mac, name, grp, vendor, !!randomized]); + return r; +} + // Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present // WITHOUT touching owner-curated name/grp/status/flagged. export async function upsertScan(rows) { diff --git a/package.json b/package.json index fac9620..5b270db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.1.2", + "version": "2.1.3", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index a882a65..57b202c 100644 --- a/public/style.css +++ b/public/style.css @@ -569,6 +569,9 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); } .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-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; } .dv-tile.flag { border-color: var(--bad); background: #1a1012; } .dv-tile.flag .dv-nm { color: var(--bad); } diff --git a/public/views/devices_band.js b/public/views/devices_band.js index 898ebe2..a7c87ba 100644 --- a/public/views/devices_band.js +++ b/public/views/devices_band.js @@ -55,6 +55,22 @@ function discoveredRow(d, onDone) { nameI, grpS, add, ignore); } +// Manual "add by MAC" form — for offline devices whose MAC you know. +function manualAddForm() { + const macI = el('input', { class: 'dv-edit-name', placeholder: 'aa:bb:cc:dd:ee:ff' }); + 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; } + try { await api.post('/api/devices', { mac, 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); +} + async function load() { if (!host) return; let data, discovered = []; @@ -76,11 +92,18 @@ async function load() { ...discovered.map(d => discoveredRow(d, load))) : null; + const addForm = manualAddForm(); + addForm.style.display = 'none'; + const addToggle = el('button', { class: 'ghost dv-addtoggle' }, '+ Add by MAC'); + addToggle.onclick = () => { addForm.style.display = addForm.style.display === 'none' ? 'flex' : 'none'; }; + 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` : ''}`)), + el('span', { class: 'dv-count' }, `${total} known${discovered.length ? ` · ${discovered.length} new` : ''}`), + addToggle), + addForm, ...sections, discPanel); } diff --git a/server.js b/server.js index 630b546..b68a316 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.2'; +const VERSION = '2.1.3'; // 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 0010826..a1ca115 100644 --- a/tests/api/devices.test.js +++ b/tests/api/devices.test.js @@ -38,4 +38,19 @@ describe('/api/devices', () => { it('PATCH rejects a bad MAC', async () => { expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400); }); + + 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' }); + expect(res.status).toBe(201); + expect(res.body.mac).toBe('aa:bb:cc:dd:ee:ff'); + 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 () => { + expect((await owner(request(app).post('/api/devices')).send({ mac: 'nope' })).status).toBe(400); + }); }); diff --git a/tests/frontend/devices_band.test.js b/tests/frontend/devices_band.test.js index a58e621..afdb8ab 100644 --- a/tests/frontend/devices_band.test.js +++ b/tests/frontend/devices_band.test.js @@ -12,6 +12,7 @@ vi.mock('../../public/api.js', () => ({ return {}; }), patch: vi.fn(async () => ({})), + post: vi.fn(async () => ({})), del: vi.fn(async () => ({})) } })); @@ -51,4 +52,16 @@ describe('devices band', () => { expect(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88', expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' })); }); + + it('manual add-by-MAC reveals a form and POSTs the new device', async () => { + const host = document.getElementById('h'); + await renderDevicesBand(host); + await new Promise(r => setTimeout(r, 0)); + 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'; + 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' })); + }); });