feat(devices): edit known devices (rename/regroup/delete) → 2.1.2

Known device tiles get a ✎ edit affordance using the existing PATCH/DELETE
/api/devices/:mac endpoints. Previously devices could only be named at promote time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
root
2026-06-08 23:02:06 +10:00
parent 2284a88bd2
commit 7a5fd88c07
6 changed files with 59 additions and 10 deletions

View File

@@ -3,6 +3,9 @@
All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).
## 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.
## 2.1.1 — OBD2 Apps rail placeholder
- **OBD2 Apps rail item** (`public/views/obd2.js`, router/app/sidebar): a placeholder launchpad under **Apps** for the parked OBD2 Telemetry project — links to the project + tasks and the research/wiki page. Swap to an `embedView` once a records UI (LubeLogger/Tracktor) is deployed.

View File

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

View File

@@ -563,6 +563,12 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
.dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); }
.dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); }
.dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; }
.dv-tile { position: relative; }
.dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
.dv-tile:hover .dv-edit-btn { opacity: 1; }
.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-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); }

View File

@@ -8,12 +8,34 @@ let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
function tile(d) {
return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') },
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')));
const t = el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (d.present === false ? ' absent' : '') });
function view() {
clear(t);
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
edit.onclick = editMode;
mount(t,
el('span', { class: 'dv-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : '')),
d.mac ? edit : null);
}
function editMode() {
clear(t);
const nameI = el('input', { class: 'dv-edit-name', value: d.name || '' });
const grpS = el('select', { class: 'dv-edit-grp' }, ...GROUPS.map(g => el('option', { value: g }, g)));
grpS.value = d.grp || 'Flagged';
const save = el('button', { class: 'dv-add' }, 'Save');
save.onclick = async () => { await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value }); load(); };
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
del.onclick = async () => { await api.del('/api/devices/' + d.mac); load(); };
const cancel = el('button', { class: 'ghost' }, 'Cancel');
cancel.onclick = view;
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, save, del, cancel);
}
view();
return t;
}
function discoveredRow(d, onDone) {

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.1';
const VERSION = '2.1.2';
// 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

@@ -6,15 +6,18 @@ vi.mock('../../public/api.js', () => ({
api: {
get: vi.fn(async (p) => {
if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', vendor: 'Netgear', randomized: false, present: true } ] } ] };
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.13', name: 'Orbi Satellite', grp: 'Network', vendor: 'Netgear', randomized: false, present: true } ] } ] };
if (p === '/api/devices/discovered') return [
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
return {};
}),
patch: vi.fn(async () => ({}))
patch: vi.fn(async () => ({})),
del: vi.fn(async () => ({}))
}
}));
import { api } from '../../public/api.js';
let renderDevicesBand;
beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
@@ -33,4 +36,19 @@ describe('devices band', () => {
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
expect(host.textContent).toMatch(/Discovered/i);
});
it('lets you edit a known device (✎ → name/group → Save patches)', async () => {
const host = document.getElementById('h');
await renderDevicesBand(host);
await new Promise(r => setTimeout(r, 0));
const t = host.querySelector('.dv-tile');
t.querySelector('.dv-edit-btn').click();
const nameI = t.querySelector('.dv-edit-name');
expect(nameI.value).toBe('Orbi Satellite');
nameI.value = 'Orbi RBS50';
t.querySelector('.dv-add').click(); // Save
await new Promise(r => setTimeout(r, 0));
expect(api.patch).toHaveBeenCalledWith('/api/devices/bc:a5:11:3e:06:88',
expect.objectContaining({ name: 'Orbi RBS50', grp: 'Network' }));
});
});