6 Commits

Author SHA1 Message Date
root
26463b5eb6 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>
2026-06-08 23:58:19 +10:00
root
88ef5786ee 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>
2026-06-08 23:12:47 +10:00
root
7a5fd88c07 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>
2026-06-08 23:02:06 +10:00
root
2284a88bd2 docs: add awesome-selfhosted as the research starting point (AGENTS.md)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:16:38 +10:00
root
607b76ff82 feat(apps): OBD2 placeholder rail item (launchpad for the parked OBD2 project)
Adds an OBD2 item to the Apps rail; with no records UI deployed yet it links to
the OBD2 Telemetry project + tasks and the research/wiki page rather than
embedding. Swap to embedView once LubeLogger/Tracktor is up. → 2.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:05:21 +10:00
root
555a4c652c docs: documentation policy — every change lands in the Void wiki AND git
Standing rule: no work is done until it's documented in both the Void wiki
(page API) and git (code + spec/plan/CHANGELOG), pushed to Gitea. Verbose-first;
consolidate later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:53:23 +10:00
14 changed files with 245 additions and 12 deletions

28
AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# Agent working agreement — Void / homelab
## Documentation policy (standing rule — do not skip)
**Every change, decision, fix, or incident must end up documented in BOTH places before the work is considered done:**
1. **The Void wiki** — the `wiki` space served by the Void. Update the relevant existing page, or create one. This is the human-facing source of truth and what `infra_audit` reads.
2. **Git** — the code plus an appropriate written artifact (a spec/plan under `docs/superpowers/`, a `CHANGELOG.md` entry, and/or a doc page), committed and **pushed** to Gitea.
Capture **verbose-first** — we consolidate/compress later. Losing information is the only failure mode; over-documenting is fine. Do this proactively as part of finishing a task, not only when asked.
## Research convention
When researching tools/projects to recommend or self-host, **start from [awesome-selfhosted](https://github.com/awesome-selfhosted/awesome-selfhosted)** (browsable at awesome-selfhosted.net) — a trusted, comprehensive, category-organised list of open-source self-hostable software. Cite it (and the relevant category) alongside other sources.
### How — wiki
Owner token: `OWNER_TOKEN` in `/opt/void-server/.env` on CT 311 (`void-app`, `192.168.1.216`). API on the LAN at `http://192.168.1.216:3000`:
- Edit a page: `PATCH /api/pages/:id` with `{ "body_md": "…", "title": "…" }`
- Create a page: `POST /api/spaces/2201a3dd-2d40-425c-a4cf-7f18882a9146/pages` with `{ "slug", "title", "body_md", "parent_id" }`
- Per-LXC / per-service pages parent under **Hosts & Services** (`ab398d61-805a-46dd-b1ba-6f09374bd7aa`).
- **Do not** write a contiguous `IP:port` for a remote-site or inactive host — `infra_audit` probes those and will false-flag them.
### How — git
Commit code + docs together and push to the project's Gitea repo:
- `void-v2``Hynes/Void-Homelab`
- `farm-timelapse``Hynes/farm-timelapse`
Specs/plans live in `docs/superpowers/{specs,plans}/`; user-facing changes get a `CHANGELOG.md` entry.

View File

@@ -3,6 +3,19 @@
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.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.
## 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.
## 2.1.0 — LAN device discovery ## 2.1.0 — LAN device discovery
- **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`, - **`lan_devices` store + hourly `arp-scan`** (`migration 024`, `lib/infra/scan.js`,
`lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and `lib/db/repos/lan_devices.js`, `lib/cron`): the Devices band is now DB-backed and

View File

@@ -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,20 @@ 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),
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()
});
// 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' } });

View File

@@ -19,6 +19,22 @@ 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, ip = null, name = null, grp = 'Flagged', vendor = null, randomized = false }) {
const { rows: [r] } = await pool.query(
`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, ip, 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) {

View File

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

View File

@@ -27,6 +27,7 @@ const VIEWS = {
terminal: () => import('./views/terminal.js'), terminal: () => import('./views/terminal.js'),
timelapse: () => import('./views/timelapse.js'), timelapse: () => import('./views/timelapse.js'),
'ai-usage': () => import('./views/aiusage.js'), 'ai-usage': () => import('./views/aiusage.js'),
obd2: () => import('./views/obd2.js'),
settings: () => import('./views/settings.js'), settings: () => import('./views/settings.js'),
jobs: () => import('./views/jobs.js') jobs: () => import('./views/jobs.js')
}; };

View File

@@ -131,7 +131,8 @@ export function renderSidebar(root) {
el('div', { class: 'sb-section' }, el('div', { class: 'sb-section' },
el('div', { class: 'sb-title' }, 'Apps'), el('div', { class: 'sb-title' }, 'Apps'),
navItem('Timelapse', '/timelapse'), navItem('Timelapse', '/timelapse'),
navItem('AI Usage', '/ai-usage') navItem('AI Usage', '/ai-usage'),
navItem('OBD2', '/obd2')
) )
); );

View File

@@ -28,6 +28,7 @@ const ROUTES = [
{ name: 'terminal', re: /^\/terminal$/, keys: [] }, { name: 'terminal', re: /^\/terminal$/, keys: [] },
{ name: 'timelapse', re: /^\/timelapse$/, keys: [] }, { name: 'timelapse', re: /^\/timelapse$/, keys: [] },
{ name: 'ai-usage', re: /^\/ai-usage$/, keys: [] }, { name: 'ai-usage', re: /^\/ai-usage$/, keys: [] },
{ name: 'obd2', re: /^\/obd2$/, keys: [] },
{ name: 'settings', re: /^\/settings$/, keys: [] }, { name: 'settings', re: /^\/settings$/, keys: [] },
{ name: 'jobs', re: /^\/jobs$/, keys: [] }, { name: 'jobs', re: /^\/jobs$/, keys: [] },
{ name: 'home', re: /^\/?$/, keys: [] } { name: 'home', re: /^\/?$/, keys: [] }

View File

@@ -563,6 +563,17 @@ 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-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-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 .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-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; } .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); }

View File

@@ -8,12 +8,34 @@ let host;
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']; const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
function tile(d) { function tile(d) {
return el('div', { class: 'dv-tile' + (d.flagged ? ' flag' : '') + (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-nm' }, d.name || 'Unknown'),
el('span', { class: 'dv-ip' }, d.ip || ''), el('span', { class: 'dv-ip' }, d.ip || ''),
d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null, d.mac ? el('span', { class: 'dv-mac' }, d.mac) : null,
el('span', { class: 'dv-vendor' }, el('span', { class: 'dv-vendor' },
(d.vendor || '') + (d.randomized ? ' · randomized' : '') + (d.present === false ? ' · absent' : ''))); (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) { function discoveredRow(d, onDone) {
@@ -33,6 +55,30 @@ function discoveredRow(d, onDone) {
nameI, grpS, add, ignore); nameI, grpS, add, ignore);
} }
// 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' } }, '');
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; }
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, ipI, nameI, grpS, add, err);
}
async function load() { async function load() {
if (!host) return; if (!host) return;
let data, discovered = []; let data, discovered = [];
@@ -54,11 +100,24 @@ 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' }, '+ 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); 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, scanBtn),
addForm,
...sections, ...sections,
discPanel); discPanel);
} }

27
public/views/obd2.js Normal file
View File

@@ -0,0 +1,27 @@
// #/obd2 — Apps rail placeholder for the OBD2 Telemetry project (parked).
// No records UI is deployed yet, so this links into the project + wiki instead of
// embedding. Swap to embedView({ src: 'https://obd2.hynesy.com/' }) once the
// LubeLogger/Tracktor dashboard is up.
import { el, mount } from '../dom.js';
import { navigate } from '../router.js';
const WIKI = '/page/bea9d582-44a2-4eec-a1ba-69ade15d3a73';
const PROJECT = '/project/02fc5b4c-12f4-4d0c-8220-6b053da71c46';
export async function render(main) {
mount(main,
el('div', { class: 'term-bar' },
el('span', { class: 'term-title' }, '◆ OBD2 Telemetry'),
el('span', { class: 'muted', style: { fontSize: '11px' } }, 'project · parked, being set up')
),
el('div', { class: 'card', style: { maxWidth: '760px' } },
el('h3', {}, 'OBD2 Telemetry — being set up'),
el('p', { class: 'muted' }, 'Capture vehicle records from the cars OBD2 port into the homelab (CT 112 · Postgres + TimescaleDB) with a maintenance/records UI. The capture pipeline is being rebuilt and the records UI isnt deployed yet — nothing to embed here yet.'),
el('p', {}, 'Plan: AndrOBD (F-Droid) + the BT ELM327 → CSV/MQTT → Timescale; WiCAN hardware later; LubeLogger / Tracktor for the UI (this tile will then embed it).'),
el('div', { style: { display: 'flex', gap: '8px', marginTop: '14px' } },
el('button', { class: 'primary', onclick: () => navigate(PROJECT) }, 'Project + tasks'),
el('button', { class: 'ghost', onclick: () => navigate(WIKI) }, 'Research / wiki')
)
)
);
}

View File

@@ -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.0'; const VERSION = '2.1.4';
// 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

View File

@@ -38,4 +38,21 @@ 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', 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 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);
});
}); });

View File

@@ -6,15 +6,19 @@ vi.mock('../../public/api.js', () => ({
api: { api: {
get: vi.fn(async (p) => { get: vi.fn(async (p) => {
if (p === '/api/devices') return { groups: [ { name: 'Network', devices: [ 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 [ if (p === '/api/devices/discovered') return [
{ mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ]; { mac: '24:4b:fe:8e:09:a4', ip: '192.168.1.15', vendor: 'ASUSTek', randomized: false, present: true } ];
return {}; return {};
}), }),
patch: vi.fn(async () => ({})) patch: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
del: vi.fn(async () => ({}))
} }
})); }));
import { api } from '../../public/api.js';
let renderDevicesBand; let renderDevicesBand;
beforeAll(async () => { beforeAll(async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' }); const dom = new JSDOM('<!doctype html><html><body><div id="h"></div></body></html>', { url: 'http://localhost/' });
@@ -33,4 +37,44 @@ describe('devices band', () => {
expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present expect(host.querySelector('.dv-discovered')).not.toBeNull(); // review affordance present
expect(host.textContent).toMatch(/Discovered/i); 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' }));
});
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, 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', 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');
});
}); });