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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.1.3",
|
||||
"version": "2.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user