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