feat(devices): Scan Now + Manual Add (IP option, MAC colon-mask) → 2.1.4

'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 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 23:58:19 +10:00
parent 88ef5786ee
commit 26463b5eb6
9 changed files with 54 additions and 17 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -1,6 +1,6 @@
{
"name": "void-server",
"version": "2.1.3",
"version": "2.1.4",
"type": "module",
"private": true,
"scripts": {

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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');
});
});