feat(devices): manually add a device by MAC (offline pre-register) → 2.1.3
'+ 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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,9 @@
|
|||||||
All notable changes to Void 2.0 are documented here.
|
All notable changes to Void 2.0 are documented here.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
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
|
## 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { asyncWrap, errorMiddleware } from '../errors.js';
|
|||||||
import { requireOwner } from '../cap.js';
|
import { requireOwner } from '../cap.js';
|
||||||
import { validate } from '../validate.js';
|
import { validate } from '../validate.js';
|
||||||
import * as devices from '../../db/repos/lan_devices.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 * as agents from '../../db/repos/agents.js';
|
||||||
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
import { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
||||||
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
||||||
@@ -67,6 +68,19 @@ router.patch('/:mac', requireOwner, validate({ params: macParam, body: patchBody
|
|||||||
res.json(updated);
|
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).
|
// DELETE /devices/:mac (owner).
|
||||||
router.delete('/:mac', requireOwner, validate({ params: macParam }), asyncWrap(async (req, res) => {
|
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' } });
|
if (!(await devices.remove(req.params.mac.toLowerCase()))) return res.status(404).json({ error: { code: 'not_found' } });
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ export async function get(mac) {
|
|||||||
return r || null;
|
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
|
// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present
|
||||||
// WITHOUT touching owner-curated name/grp/status/flagged.
|
// WITHOUT touching owner-curated name/grp/status/flagged.
|
||||||
export async function upsertScan(rows) {
|
export async function upsertScan(rows) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "void-server",
|
"name": "void-server",
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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-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-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-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 .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 { border-color: var(--bad); background: #1a1012; }
|
||||||
.dv-tile.flag .dv-nm { color: var(--bad); }
|
.dv-tile.flag .dv-nm { color: var(--bad); }
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ function discoveredRow(d, onDone) {
|
|||||||
nameI, grpS, add, ignore);
|
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() {
|
async function load() {
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
let data, discovered = [];
|
let data, discovered = [];
|
||||||
@@ -76,11 +92,18 @@ async function load() {
|
|||||||
...discovered.map(d => discoveredRow(d, load)))
|
...discovered.map(d => discoveredRow(d, load)))
|
||||||
: null;
|
: 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);
|
clear(host);
|
||||||
mount(host,
|
mount(host,
|
||||||
el('div', { class: 'dv-hd' },
|
el('div', { class: 'dv-hd' },
|
||||||
el('div', { class: 'dv-title' }, 'Network · Devices'),
|
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,
|
...sections,
|
||||||
discPanel);
|
discPanel);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
|||||||
import { handleMcp } from './lib/mcp/http.js';
|
import { handleMcp } from './lib/mcp/http.js';
|
||||||
import httpProxy from 'http-proxy';
|
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
|
// 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
|
// works whether the Void is reached via Traefik (void2-app.hynesy.com) OR the
|
||||||
|
|||||||
@@ -38,4 +38,19 @@ describe('/api/devices', () => {
|
|||||||
it('PATCH rejects a bad MAC', async () => {
|
it('PATCH rejects a bad MAC', async () => {
|
||||||
expect((await owner(request(app).patch('/api/devices/not-a-mac')).send({ name: 'x' })).status).toBe(400);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock('../../public/api.js', () => ({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
patch: vi.fn(async () => ({})),
|
patch: vi.fn(async () => ({})),
|
||||||
|
post: vi.fn(async () => ({})),
|
||||||
del: 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(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88',
|
||||||
expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' }));
|
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' }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user