Compare commits
6 Commits
feat/lan-d
...
26463b5eb6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26463b5eb6 | ||
|
|
88ef5786ee | ||
|
|
7a5fd88c07 | ||
|
|
2284a88bd2 | ||
|
|
607b76ff82 | ||
|
|
555a4c652c |
28
AGENTS.md
Normal file
28
AGENTS.md
Normal 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.
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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' } });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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: [] }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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
27
public/views/obd2.js
Normal 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 car’s OBD2 port into the homelab (CT 112 · Postgres + TimescaleDB) with a maintenance/records UI. The capture pipeline is being rebuilt and the records UI isn’t 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')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user