diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d56160..a4d6c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 9c893bf..fac9620 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-server", - "version": "2.1.1", + "version": "2.1.2", "type": "module", "private": true, "scripts": { diff --git a/public/style.css b/public/style.css index 138005b..a882a65 100644 --- a/public/style.css +++ b/public/style.css @@ -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); } diff --git a/public/views/devices_band.js b/public/views/devices_band.js index 79d0212..898ebe2 100644 --- a/public/views/devices_band.js +++ b/public/views/devices_band.js @@ -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) { diff --git a/server.js b/server.js index ce04fdf..630b546 100644 --- a/server.js +++ b/server.js @@ -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 diff --git a/tests/frontend/devices_band.test.js b/tests/frontend/devices_band.test.js index 3f6fdec..a58e621 100644 --- a/tests/frontend/devices_band.test.js +++ b/tests/frontend/devices_band.test.js @@ -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('
', { 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' })); + }); });