Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15de56dbe6 | ||
|
|
442bb6ccc9 | ||
|
|
ea20c55917 | ||
|
|
4ef7fa2d75 | ||
|
|
b17cdb7f77 | ||
|
|
b967c0bfdd | ||
|
|
16e324102e | ||
|
|
18eba2d911 | ||
|
|
b16456fc1b | ||
|
|
cc82b16f0a | ||
|
|
1a28a5e57e | ||
|
|
fdf282b845 | ||
|
|
26a9be51d0 | ||
|
|
e309c32d8f | ||
|
|
086bd1e6a3 | ||
|
|
24d7bd72b4 | ||
|
|
3ea150bad1 | ||
|
|
5e38208eb3 | ||
|
|
d317f0e314 | ||
|
|
2bf66ec570 | ||
|
|
0e9c8affd4 | ||
|
|
055a88932e | ||
|
|
69f1df2789 | ||
|
|
b049aedd22 | ||
|
|
4efeca74b2 | ||
|
|
9e99e0664f | ||
|
|
207ea906ee | ||
|
|
bfecb757b4 | ||
|
|
1626b3f80d | ||
|
|
59aba14ef7 | ||
|
|
0e55fdef42 | ||
|
|
2f89a1aa50 | ||
|
|
1b960ec52b |
@@ -3,6 +3,10 @@
|
||||
All notable changes to Void 2.0 are documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||
|
||||
## 2.4.0 — Storage · capacity card (Sacred Valley)
|
||||
- **New "Storage · capacity" card** (`public/views/cards/storage.js`, `/api/storage`, `lib/proxmox/storage.js`) — read-only Proxmox health via the same `PROXMOX_RO_TOKEN` as the cluster card. Shows: **ZFS pools** (health + usage meter), **dropped pools** (a configured zfspool storage that's no longer `available` — the donatello/leonardo SATA-bus signal, rendered red), and **per-container disk fill** (top LXC by rootfs %), with a HEALTHY/WATCH/ATTENTION roll-up badge. Thresholds: 80% warn, 90% crit; a non-ONLINE or dropped pool is always crit.
|
||||
- Closes the monitoring gap from the 2026-06-09 audit (the Void couldn't previously see C1 = pools offline or H2 = a container at 95%). Pure `normalizeStorage()` is unit-tested.
|
||||
|
||||
## 2.3.0 — MagicMirror² as a Void app
|
||||
- **New "MagicMirror" Apps view** (`#/mirror`, `public/views/mirror.js`) — embeds the smart-mirror dashboard (CT 111) via the shared `embedView` factory, like Timelapse / AI Usage.
|
||||
- **Exposure:** MagicMirror (LAN-only `192.168.1.224:8080`) is now published at **mirror.hynesy.com** through Traefik + the `*.hynesy.com` tunnel, private behind **CF Access** (Farm policy / Google IdP). A Traefik `mirror-frame` middleware replaces MM's `X-Frame-Options: SAMEORIGIN` with a CSP `frame-ancestors` allowing the Void origins so the iframe renders.
|
||||
|
||||
1055
docs/superpowers/plans/2026-06-09-device-icons.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Device icons, last-seen timer & uploadable icon sets — design
|
||||
|
||||
Date: 2026-06-09
|
||||
Feature area: Void dashboard → LAN Devices band (`lan_devices`, migration 024)
|
||||
|
||||
## Goal
|
||||
Let the user assign an icon to each discovered LAN device (device-type icon OR
|
||||
brand logo — "both"), show how long ago an absent device was last seen, and
|
||||
manage/extend the available icons by uploading new icon sets from Settings.
|
||||
|
||||
## Background (existing code reused)
|
||||
- `lan_devices` table (migration 024): MAC-keyed inventory; already has
|
||||
`last_seen timestamptz` and `present boolean`. No icon column yet.
|
||||
- `public/views/devices_band.js`: renders tiles + an edit (✎) flow; `/api/devices`
|
||||
PATCH (`lib/api/routes/devices.js`, zod `patchBody`).
|
||||
- **Existing icon proxy** (reused for brand logos): `GET /api/icons/:slug.png`
|
||||
→ `lib/health/icons.js#getIcon()` fetches `walkxcode/dashboard-icons` PNGs via
|
||||
jsDelivr and caches them to `/var/lib/void/icons`. `validSlug = ^[a-z0-9-]+$`.
|
||||
`public/components/service_tile.js` renders `<img src=/api/icons/${slug}.png>`
|
||||
with a letter fallback on error.
|
||||
|
||||
## Icon model
|
||||
A device's `icon` value is one of:
|
||||
- `set:<set>:<name>` → bundled/uploaded type icon, served `/api/icon-sets/<set>/<name>`
|
||||
- `brand:<slug>` → dashboard-icons logo, served `/api/icons/<slug>.png` (existing)
|
||||
- `NULL` → auto-default chosen by group/vendor (pure function)
|
||||
|
||||
Auto-default mapping (group → bundled `devices` set):
|
||||
Network→router, Entertainment→tv, Smart Home→plug, Personal→phone, else→unknown.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Data — migration 02x
|
||||
`ALTER TABLE lan_devices ADD COLUMN icon text;` (nullable). No backfill (NULL =
|
||||
auto-default). Down: drop column.
|
||||
|
||||
### 2. Bundled type-icon set (the "set of favicons")
|
||||
Download ~15 **Tabler Icons** (MIT) SVGs into the repo at
|
||||
`public/icons/devices/` as the read-only bundled set named `devices`:
|
||||
router, phone, tablet, laptop, desktop, tv, speaker, camera, printer, console,
|
||||
plug, server, watch, nas, unknown. Monochrome line icons → match blackflame.
|
||||
|
||||
### 3. Uploadable icon sets (persistent, outside git)
|
||||
- Storage: `/var/lib/void/icon-sets/<set>/<name>.(svg|png)` (persistent volume,
|
||||
survives redeploys — NOT in git-tracked `public/`). Env override
|
||||
`ICON_SETS_DIR`, default `/var/lib/void/icon-sets`.
|
||||
- A "set" is a directory of icon files. Set/name validated `^[a-z0-9-]+$`.
|
||||
- **Three ingest methods**, all converging on the same per-file processor:
|
||||
1. **Multi-file** — one or more SVG/PNG files.
|
||||
2. **Zip archive** — server unpacks; each entry runs the per-file processor.
|
||||
Reject path traversal / absolute paths / nested dirs (flatten basenames);
|
||||
skip non-image entries; cap entry count + uncompressed total (zip-bomb
|
||||
guard).
|
||||
3. **URL ingest** — server fetches a remote URL; if the payload is a zip it is
|
||||
unpacked (as above), otherwise treated as a single image. http/https only
|
||||
(scheme allowlist, SSRF guard), 8 s timeout, total size cap.
|
||||
- **Per-file processor (shared):** validate name slug + extension; magic-byte
|
||||
check (PNG/JPEG/SVG); **sanitize SVGs** (strip `<script>`, `on*` handlers,
|
||||
external refs — uploaded SVGs render inline → XSS risk even behind CF Access);
|
||||
enforce per-file size cap (e.g. 256 KB); write into the set dir.
|
||||
|
||||
### 4. API (`lib/api/routes/`)
|
||||
- `GET /api/icon-sets` → `[{ set, readonly, icons:[name…] }]` (bundled `devices`
|
||||
scanned from `public/icons/devices`, uploads scanned from ICON_SETS_DIR).
|
||||
- `GET /api/icon-sets/:set/:file` → serve the icon (correct Content-Type;
|
||||
Cache-Control). Validates slugs; 404 on miss.
|
||||
- `POST /api/icon-sets/:set` (requireOwner) → create/extend a set. Accepts
|
||||
EITHER multipart files (one or more SVG/PNG, and/or a `.zip`) OR a JSON body
|
||||
`{ url }` for URL ingest. All inputs run through the shared per-file processor
|
||||
(§3). Returns the updated set. SSRF guard + size/timeout caps on URL ingest.
|
||||
- `DELETE /api/icon-sets/:set` (requireOwner) → remove an uploaded set; the
|
||||
bundled `devices` set is read-only (409).
|
||||
- Extend `patchBody` in `devices.js` with
|
||||
`icon: z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable().optional()`.
|
||||
- Ensure `GET /api/devices` returns `icon` and `last_seen`.
|
||||
|
||||
### 5. Frontend — `devices_band.js`
|
||||
- `resolveIcon(iconRef)` (pure): `set:` → `/api/icon-sets/<set>/<name>`;
|
||||
`brand:` → `/api/icons/<slug>.png`; null → auto-default → `set:devices:<name>`.
|
||||
`<img>` with the existing letter fallback on error.
|
||||
- Tile shows the icon. Edit (✎) mode gains an **icon picker** with two tabs:
|
||||
- **Type**: grid grouped by set (bundled `devices` first, then uploads),
|
||||
fetched from `GET /api/icon-sets`.
|
||||
- **Brand**: a search box → live preview from `/api/icons/<slug>.png`.
|
||||
Selecting writes `icon` via the existing PATCH.
|
||||
- `relativeTime(ts)` (pure): <60s "just now"; <60m "Nm ago"; <24h "Nh ago";
|
||||
else "Nd ago". Shown as "seen Nh ago" on **absent** tiles only (present tiles
|
||||
keep the online dot).
|
||||
|
||||
### 6. Settings — expandable "Icon sets" section
|
||||
- Collapsible panel (`public/views/settings*` pattern): lists each set as a grid
|
||||
of its icons; uploaded sets get a Delete; bundled `devices` is read-only.
|
||||
- Upload control: a set-name field plus three inputs → `POST /api/icon-sets/:set`,
|
||||
refresh the list on success:
|
||||
- multi-file picker (SVG/PNG),
|
||||
- zip picker (`.zip`),
|
||||
- a URL field ("ingest from URL" — image or zip).
|
||||
|
||||
## Testing (vitest)
|
||||
Pure/unit: `resolveIcon`, `relativeTime`, auto-default mapping, SVG sanitizer,
|
||||
slug validation, `patchBody` icon regex, zip-entry guard (traversal/zip-bomb),
|
||||
URL SSRF/scheme guard. Integration: icon-sets list/upload/delete (tmp dir via
|
||||
env override), multi-file + zip + URL ingest (mock fetcher) all landing files,
|
||||
devices PATCH accepts/round-trips `icon`, GET returns icon+last_seen. Reuse the
|
||||
existing icon-proxy test patterns.
|
||||
|
||||
## Deploy
|
||||
Per backup-before-major-updates: `pct snapshot` void-db (310) + void-app (311),
|
||||
run the migration, deploy via the health-gated script, headless-render the
|
||||
Devices band + Settings panel to confirm icons + picker + last-seen display.
|
||||
Ensure ICON_SETS_DIR exists + is writable by the `void` user; document the env
|
||||
var. Commit + push to Gitea `Hynes/Void-Homelab`; wiki page update.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
Per-icon recolor/theming, auto-icon-by-vendor guessing beyond the group default,
|
||||
icon sets shared across other Void features, scraping a whole remote icon-pack
|
||||
repo (URL ingest is single-file or single-zip, not a directory crawl).
|
||||
@@ -34,6 +34,8 @@ import { router as littleblueRouter } from './routes/littleblue.js';
|
||||
import { router as aiUsageRouter } from './routes/ai_usage.js';
|
||||
import { router as infraRouter } from './routes/infra.js';
|
||||
import { router as clusterRouter } from './routes/cluster.js';
|
||||
import { router as storageRouter } from './routes/storage.js';
|
||||
import { router as backupsRouter } from './routes/backups.js';
|
||||
import { router as kuttRouter } from './routes/kutt.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
@@ -50,6 +52,8 @@ export function mountApi(app) {
|
||||
api.use('/actions', actionsRouter);
|
||||
api.use('/infra', infraRouter);
|
||||
api.use('/cluster', clusterRouter);
|
||||
api.use('/storage', storageRouter);
|
||||
api.use('/backups', backupsRouter);
|
||||
api.use('/little-blue', littleblueRouter);
|
||||
api.use('/ai-usage', aiUsageRouter);
|
||||
api.use('/projects', projectsRouter);
|
||||
|
||||
30
lib/api/routes/backups.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import * as backups from '../../db/repos/backups.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
export const ingest = z.object({
|
||||
ok: z.boolean().optional(),
|
||||
total_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||
won_free_bytes: z.number().int().nonnegative().nullable().optional(),
|
||||
guests: z.array(z.object({
|
||||
vmid: z.union([z.number().int(), z.string()]),
|
||||
name: z.string().max(64),
|
||||
bytes: z.number().int().nonnegative()
|
||||
})).max(50).nullable().optional(),
|
||||
duration_sec: z.number().int().nonnegative().nullable().optional()
|
||||
});
|
||||
|
||||
// POST /api/backups — the offsite-backup script reports a run (owner only).
|
||||
router.post('/', requireOwner, validate({ body: ingest }), asyncWrap(async (req, res) => {
|
||||
res.status(201).json(await backups.record(req.body));
|
||||
}));
|
||||
|
||||
// GET /api/backups — latest run + count, for the Sacred Valley "Backups" card.
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
res.json({ latest: await backups.latest(), count: await backups.count(), schedule: 'Sun 02:00' });
|
||||
}));
|
||||
@@ -5,32 +5,9 @@ import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.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 { timingSafeStrEqual } from '../../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../../auth/cf_access.js';
|
||||
import { softAuth } from '../soft_auth.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
const GROUP_ORDER = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
|
||||
router.use(softAuth);
|
||||
@@ -53,12 +30,14 @@ router.get('/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
}));
|
||||
|
||||
const macParam = z.object({ mac: z.string().regex(/^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/i) });
|
||||
export const iconRef = z.string().regex(/^(set:[a-z0-9-]+:[a-z0-9-]+|brand:[a-z0-9-]+)$/).nullable();
|
||||
const patchBody = z.object({
|
||||
name: z.string().max(120).optional(),
|
||||
grp: z.enum(['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged']).optional(),
|
||||
status: z.enum(['new', 'known', 'ignored']).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
flagged: z.boolean().optional()
|
||||
flagged: z.boolean().optional(),
|
||||
icon: iconRef.optional()
|
||||
});
|
||||
|
||||
// PATCH /devices/:mac — name / edit / promote (owner). This is "add from discovered".
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireOwner } from '../cap.js';
|
||||
import { validate } from '../validate.js';
|
||||
import { grouped, iconSlug } from '../../health/registry.js';
|
||||
import * as services from '../../db/repos/monitored_services.js';
|
||||
import * as devices from '../../db/repos/lan_devices.js';
|
||||
import * as statusRepo from '../../db/repos/service_status.js';
|
||||
import { enqueue } from '../../jobs/queue.js';
|
||||
|
||||
@@ -29,7 +30,13 @@ router.get('/services', asyncWrap(async (_req, res) => {
|
||||
|
||||
// GET /services/discovered — candidates from a LAN scan, awaiting review (owner).
|
||||
router.get('/services/discovered', requireOwner, asyncWrap(async (_req, res) => {
|
||||
res.json((await services.listDiscovered()).map(s => ({ ...s, icon: iconSlug(s) })));
|
||||
// Cross-reference each candidate's host IP with the Network Devices band so the
|
||||
// tile can show a known device name instead of a bare IP:port.
|
||||
const byIp = Object.fromEntries(
|
||||
(await devices.listKnown()).filter(d => d.ip).map(d => [d.ip, d.name]));
|
||||
res.json((await services.listDiscovered()).map(s => ({
|
||||
...s, icon: iconSlug(s), device: byIp[s.host] || null
|
||||
})));
|
||||
}));
|
||||
|
||||
const checkCfg = z.object({ type: z.enum(['http', 'tcp']).optional(), path: z.string().max(200).optional() });
|
||||
|
||||
57
lib/api/routes/icon_sets.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// lib/api/routes/icon_sets.js
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { requireOwner } from '../cap.js';
|
||||
import { asyncWrap, errorMiddleware } from '../errors.js';
|
||||
import { softAuth } from '../soft_auth.js';
|
||||
import * as sets from '../../icons/sets.js';
|
||||
import { processFile, unpackZip, fetchUrl, isZip } from '../../icons/ingest.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(softAuth);
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 6 * 1024 * 1024, files: 50 } });
|
||||
|
||||
// GET /api/icon-sets — list sets + their icons (open; <img> can't send bearer).
|
||||
router.get('/', asyncWrap(async (_req, res) => res.json(await sets.listSets())));
|
||||
|
||||
// GET /api/icon-sets/:set/:file — serve one icon.
|
||||
router.get('/:set/:file', asyncWrap(async (req, res) => {
|
||||
let buf;
|
||||
try { buf = await sets.readIcon(req.params.set, req.params.file); }
|
||||
catch (e) { return res.status(e.message === 'bad_slug' ? 400 : 404).end(); }
|
||||
const ct = req.params.file.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
|
||||
// no-cache => browsers/CF revalidate (304 via Express's ETag when unchanged), so
|
||||
// icon updates propagate immediately instead of being stuck for a day. Icons are
|
||||
// tiny, so the revalidation cost is negligible.
|
||||
res.set('Content-Type', ct).set('Cache-Control', 'no-cache').send(buf);
|
||||
}));
|
||||
|
||||
// POST /api/icon-sets/:set — owner upload: multipart files (incl .zip) and/or { url }.
|
||||
router.post('/:set', requireOwner, upload.array('files'), asyncWrap(async (req, res) => {
|
||||
const set = req.params.set;
|
||||
const items = []; // [{name, buffer}]
|
||||
for (const f of req.files || []) {
|
||||
if (isZip(f.buffer)) items.push(...unpackZip(f.buffer));
|
||||
else items.push(processFile({ name: f.originalname, buffer: f.buffer }));
|
||||
}
|
||||
if (req.body?.url) {
|
||||
const { buffer } = await fetchUrl(req.body.url);
|
||||
if (isZip(buffer)) items.push(...unpackZip(buffer));
|
||||
else {
|
||||
const name = new URL(req.body.url).pathname.split('/').pop() || 'icon.png';
|
||||
items.push(processFile({ name, buffer }));
|
||||
}
|
||||
}
|
||||
if (!items.length) return res.status(400).json({ error: { code: 'no_icons' } });
|
||||
for (const it of items) await sets.writeIcon(set, it.name, it.buffer);
|
||||
res.json((await sets.listSets()).find(s => s.set === set) || { set, icons: [] });
|
||||
}));
|
||||
|
||||
// DELETE /api/icon-sets/:set — owner remove an uploaded set.
|
||||
router.delete('/:set', requireOwner, asyncWrap(async (req, res) => {
|
||||
try { await sets.deleteSet(req.params.set); }
|
||||
catch (e) { return res.status(e.message === 'reserved_set' ? 409 : 400).json({ error: { code: e.message } }); }
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
router.use(errorMiddleware);
|
||||
17
lib/api/routes/storage.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
import { storageHealth } from '../../proxmox/storage.js';
|
||||
|
||||
// Read-only storage/capacity health for the Sacred Valley card. Cached briefly so
|
||||
// multiple polling clients coalesce into one set of PVE calls. Owner or any authed agent.
|
||||
export const router = Router();
|
||||
|
||||
let cache = { at: 0, data: null };
|
||||
const TTL = 15_000;
|
||||
|
||||
router.get('/', asyncWrap(async (_req, res) => {
|
||||
if (cache.data && Date.now() - cache.at < TTL) return res.json(cache.data);
|
||||
const data = await storageHealth();
|
||||
cache = { at: Date.now(), data };
|
||||
res.json(data);
|
||||
}));
|
||||
25
lib/api/soft_auth.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// lib/api/soft_auth.js — shared middleware
|
||||
// Soft auth: identifies the actor if auth is present but never blocks the request.
|
||||
// Owner-only sub-routes enforce 401/403 via requireOwner.
|
||||
import * as agents from '../db/repos/agents.js';
|
||||
import { timingSafeStrEqual } from '../auth/safe_compare.js';
|
||||
import { accessOwnerEmail } from '../auth/cf_access.js';
|
||||
|
||||
export async function softAuth(req, _res, next) {
|
||||
try {
|
||||
const cfEmail = await accessOwnerEmail(req);
|
||||
if (cfEmail) { req.actor = { kind: 'user', id: null }; return next(); }
|
||||
const auth = req.headers.authorization || '';
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme === 'Bearer' && token) {
|
||||
if (process.env.OWNER_TOKEN && timingSafeStrEqual(token, process.env.OWNER_TOKEN)) {
|
||||
req.actor = { kind: 'user', id: null }; return next();
|
||||
}
|
||||
try {
|
||||
const agent = await agents.verifyToken(token);
|
||||
if (agent) req.actor = { kind: 'agent', id: agent.id, capabilities: agent.capabilities || {}, scopes: agent.scopes || {} };
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
next();
|
||||
}
|
||||
4
lib/db/migrations/025_lan_device_icon.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- 025_lan_device_icon.sql
|
||||
-- Per-device icon reference: 'set:<set>:<name>' (type icon) or 'brand:<slug>'
|
||||
-- (dashboard-icons logo). NULL => UI auto-defaults from the device group.
|
||||
ALTER TABLE lan_devices ADD COLUMN IF NOT EXISTS icon text;
|
||||
12
lib/db/migrations/026_backup_runs.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 026_backup_runs.sql
|
||||
-- Offsite DR backup run history, fed by /usr/local/bin/offsite-backup.sh on CT 300
|
||||
-- (Core-4 vzdump -> Farm/Won). Powers the Sacred Valley "Backups" card.
|
||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||
id serial PRIMARY KEY,
|
||||
ran_at timestamptz NOT NULL DEFAULT now(),
|
||||
ok boolean NOT NULL DEFAULT true,
|
||||
total_bytes bigint,
|
||||
won_free_bytes bigint,
|
||||
guests jsonb, -- [{vmid,name,bytes}]
|
||||
duration_sec integer
|
||||
);
|
||||
21
lib/db/repos/backups.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
export async function record({ ok = true, total_bytes = null, won_free_bytes = null,
|
||||
guests = null, duration_sec = null }) {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO backup_runs (ok, total_bytes, won_free_bytes, guests, duration_sec)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
|
||||
[ok, total_bytes, won_free_bytes, guests ? JSON.stringify(guests) : null, duration_sec]);
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function latest() {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`SELECT * FROM backup_runs ORDER BY id DESC LIMIT 1`);
|
||||
return r || null;
|
||||
}
|
||||
|
||||
export async function count() {
|
||||
const { rows: [r] } = await pool.query(`SELECT count(*)::int AS n FROM backup_runs`);
|
||||
return r.n;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
|
||||
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present, icon';
|
||||
|
||||
export async function listKnown() {
|
||||
const { rows } = await pool.query(
|
||||
@@ -70,7 +70,7 @@ export async function prune() {
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
|
||||
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged', 'icon'];
|
||||
export async function update(mac, patch) {
|
||||
const sets = [], vals = [];
|
||||
for (const k of PATCHABLE) {
|
||||
|
||||
129
lib/icons/ingest.js
Normal file
@@ -0,0 +1,129 @@
|
||||
// lib/icons/ingest.js
|
||||
import path from 'node:path';
|
||||
import dns from 'node:dns';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { sanitizeSvg } from './sanitize.js';
|
||||
|
||||
export const MAX_FILE = 256 * 1024; // 256 KB per icon
|
||||
export const MAX_ZIP_ENTRIES = 200;
|
||||
export const MAX_ZIP_TOTAL = 5 * 1024 * 1024; // 5 MB uncompressed
|
||||
export const MAX_URL_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const EXT = { '.svg': 'image/svg+xml', '.png': 'image/png' };
|
||||
const PNG_SIG = [0x89,0x50,0x4e,0x47];
|
||||
|
||||
function slugBase(name) {
|
||||
return path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
function magicOk(ext, buf) {
|
||||
if (ext === '.png') return PNG_SIG.every((b, i) => buf[i] === b);
|
||||
if (ext === '.svg') return buf.toString('utf8', 0, 400).includes('<svg');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate + normalize one icon. Returns { name, buffer, ext, contentType }. Throws on invalid.
|
||||
export function processFile({ name, buffer }) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (!EXT[ext]) throw new Error('unsupported_type');
|
||||
if (!buffer || buffer.length === 0) throw new Error('empty');
|
||||
if (buffer.length > MAX_FILE) throw new Error('too_large');
|
||||
if (!magicOk(ext, buffer)) throw new Error('bad_magic');
|
||||
const base = slugBase(name);
|
||||
if (!base) throw new Error('bad_name');
|
||||
const out = ext === '.svg' ? Buffer.from(sanitizeSvg(buffer)) : buffer;
|
||||
return { name: `${base}${ext}`, buffer: out, ext, contentType: EXT[ext] };
|
||||
}
|
||||
|
||||
// Extract image entries from a zip buffer; flatten basenames, skip traversal/junk.
|
||||
export function unpackZip(buffer) {
|
||||
const zip = new AdmZip(buffer);
|
||||
const entries = zip.getEntries();
|
||||
if (entries.length > MAX_ZIP_ENTRIES) throw new Error('too_many_entries');
|
||||
const out = []; let total = 0;
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory) continue;
|
||||
const ext = path.extname(e.entryName).toLowerCase();
|
||||
if (!EXT[ext]) continue; // skip non-images
|
||||
if (/(^|[\\/])\.\.([\\/]|$)/.test(e.entryName)) continue; // skip traversal
|
||||
const data = e.getData();
|
||||
total += data.length;
|
||||
if (total > MAX_ZIP_TOTAL) throw new Error('zip_too_big');
|
||||
try { out.push(processFile({ name: path.basename(e.entryName), buffer: data })); }
|
||||
catch { /* skip individually-invalid entries */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PRIVATE_HOST = /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?)/i;
|
||||
|
||||
/**
|
||||
* Returns true if the given IP string is a blocked (loopback, private,
|
||||
* link-local, or ULA) address that should not be fetched.
|
||||
* Handles both IPv4 and IPv6.
|
||||
*/
|
||||
export function isBlockedAddress(ip) {
|
||||
if (!ip) return true;
|
||||
// IPv6 loopback
|
||||
if (ip === '::1') return true;
|
||||
// IPv4-mapped loopback ::ffff:127.x.x.x
|
||||
const v4mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
const v4 = v4mapped ? v4mapped[1] : ip;
|
||||
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(v4)) {
|
||||
const parts = v4.split('.').map(Number);
|
||||
const [a, b] = parts;
|
||||
// 0.0.0.0
|
||||
if (a === 0) return true;
|
||||
// 127.0.0.0/8 — loopback
|
||||
if (a === 127) return true;
|
||||
// 10.0.0.0/8 — private
|
||||
if (a === 10) return true;
|
||||
// 172.16.0.0/12 — private
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16 — private
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 169.254.0.0/16 — link-local
|
||||
if (a === 169 && b === 254) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 checks (expand to lower-case for prefix matching)
|
||||
const lower = ip.toLowerCase();
|
||||
// ::1 is caught above; handle full-form loopback
|
||||
if (lower === '0:0:0:0:0:0:0:1') return true;
|
||||
// fe80::/10 — link-local (fe80 – febf)
|
||||
if (/^fe[89ab][0-9a-f]:/i.test(lower)) return true;
|
||||
// fc00::/7 — ULA (fc00 – fdff)
|
||||
if (/^f[cd][0-9a-f]{2}:/i.test(lower)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function dnsLookupAll(hostname) {
|
||||
return new Promise((resolve, reject) =>
|
||||
dns.lookup(hostname, { all: true }, (err, addrs) => err ? reject(err) : resolve(addrs))
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch a remote icon or zip. SSRF guard: http/https only, no localhost/private,
|
||||
// DNS-resolved address check, size + timeout caps. `fetcher` injectable for tests.
|
||||
export async function fetchUrl(url, { fetcher } = {}) {
|
||||
let u;
|
||||
try { u = new URL(url); } catch { throw new Error('bad_url'); }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad_scheme');
|
||||
// Fast literal-hostname guard (catches raw IP strings and 'localhost' without DNS)
|
||||
if (PRIVATE_HOST.test(u.hostname)) throw new Error('blocked_host');
|
||||
// DNS resolution guard — only when using the real fetcher (not in tests)
|
||||
if (!fetcher) {
|
||||
const addrs = await dnsLookupAll(u.hostname).catch(() => []);
|
||||
if (addrs.some(a => isBlockedAddress(a.address))) throw new Error('blocked_host');
|
||||
}
|
||||
const realFetcher = fetcher ?? fetch;
|
||||
const res = await realFetcher(url, { signal: AbortSignal.timeout(8000), redirect: 'error' });
|
||||
if (!res.ok) throw new Error('fetch_failed');
|
||||
const ab = await res.arrayBuffer();
|
||||
if (ab.byteLength > MAX_URL_BYTES) throw new Error('too_large');
|
||||
return { buffer: Buffer.from(ab) };
|
||||
}
|
||||
|
||||
export function isZip(buf) { return buf && buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; }
|
||||
16
lib/icons/sanitize.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// lib/icons/sanitize.js
|
||||
// Focused SVG sanitizer for owner-uploaded icons. NOT a general-purpose
|
||||
// sanitizer — it removes the script/handler/foreignObject/js-uri vectors that
|
||||
// matter for inline-rendered icons. (Owner-only upload behind CF Access.)
|
||||
export function sanitizeSvg(input) {
|
||||
let s = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
|
||||
s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
s = s.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '');
|
||||
s = s.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '');
|
||||
s = s.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '');
|
||||
// Unquoted handlers, e.g. <svg onload=alert(1)>. Value runs until whitespace,
|
||||
// quote, or the tag's closing > / />.
|
||||
s = s.replace(/\son[a-z]+\s*=\s*[^\s">]+/gi, '');
|
||||
s = s.replace(/(href|xlink:href)\s*=\s*("|')\s*javascript:[^"']*\2/gi, '$1=$2#$2');
|
||||
return s;
|
||||
}
|
||||
52
lib/icons/sets.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// lib/icons/sets.js
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const BUNDLED_SET = 'devices'; // read-only, ships in public/icons/devices
|
||||
let setsDir = process.env.ICON_SETS_DIR || '/var/lib/void/icon-sets';
|
||||
let bundledDir = path.resolve('public/icons/devices');
|
||||
export function _setDirs({ setsDir: s, bundledDir: b }) { if (s) setsDir = s; if (b) bundledDir = b; }
|
||||
|
||||
const SLUG = /^[a-z0-9-]+$/;
|
||||
const FILE = /^[a-z0-9-]+\.(svg|png)$/;
|
||||
function okSet(s) { return SLUG.test(s); }
|
||||
|
||||
async function listDir(dir) {
|
||||
try { return (await readdir(dir)).filter(f => FILE.test(f)).sort(); } catch { return []; }
|
||||
}
|
||||
|
||||
export async function listSets() {
|
||||
const out = [{ set: BUNDLED_SET, readonly: true, icons: await listDir(bundledDir) }];
|
||||
let uploaded = [];
|
||||
try { uploaded = await readdir(setsDir, { withFileTypes: true }); } catch { /* none yet */ }
|
||||
for (const d of uploaded) {
|
||||
if (d.isDirectory() && okSet(d.name) && d.name !== BUNDLED_SET) {
|
||||
out.push({ set: d.name, readonly: false, icons: await listDir(path.join(setsDir, d.name)) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Resolve an on-disk path for serving. Throws on bad slugs.
|
||||
export function iconPath(set, file) {
|
||||
if (!okSet(set) || !FILE.test(file)) throw new Error('bad_slug');
|
||||
return set === BUNDLED_SET ? path.join(bundledDir, file) : path.join(setsDir, set, file);
|
||||
}
|
||||
|
||||
export async function readIcon(set, file) {
|
||||
return readFile(iconPath(set, file));
|
||||
}
|
||||
|
||||
export async function writeIcon(set, name, buffer) {
|
||||
if (set === BUNDLED_SET) throw new Error('reserved_set');
|
||||
if (!okSet(set) || !FILE.test(name)) throw new Error('bad_slug');
|
||||
const dir = path.join(setsDir, set);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, name), buffer);
|
||||
}
|
||||
|
||||
export async function deleteSet(set) {
|
||||
if (set === BUNDLED_SET) throw new Error('reserved_set');
|
||||
if (!okSet(set)) throw new Error('bad_slug');
|
||||
await rm(path.join(setsDir, set), { recursive: true, force: true });
|
||||
}
|
||||
94
lib/proxmox/storage.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Agent } from 'undici';
|
||||
|
||||
// Read-only Proxmox storage + capacity health for the Sacred Valley card. Same
|
||||
// PVEAuditor token as the cluster card (PROXMOX_RO_TOKEN). Surfaces the two things
|
||||
// that have actually bitten this homelab and were previously invisible:
|
||||
// 1. a ZFS pool dropping out (the donatello/leonardo SATA-bus incident) — seen as
|
||||
// a zfspool storage whose status is no longer 'available'.
|
||||
// 2. a container rootfs filling up (mediastack hitting 95%) — per-LXC disk/maxdisk.
|
||||
|
||||
let insecure;
|
||||
function tlsDispatcher() {
|
||||
if (process.env.PROXMOX_INSECURE_TLS !== '1') return undefined;
|
||||
insecure ??= new Agent({ connect: { rejectUnauthorized: false } });
|
||||
return insecure;
|
||||
}
|
||||
|
||||
async function pveGet(path, { apiUrl, token, fetchImpl = fetch }) {
|
||||
const res = await fetchImpl(`${apiUrl}/api2/json${path}`, {
|
||||
headers: { Authorization: `PVEAPIToken=${token}` },
|
||||
dispatcher: tlsDispatcher()
|
||||
});
|
||||
if (!res.ok) throw new Error(`pve ${path} -> ${res.status}`);
|
||||
return (await res.json())?.data ?? [];
|
||||
}
|
||||
|
||||
export const WARN = 80, CRIT = 90;
|
||||
const pct = (used, total) => (total > 0 ? Math.round((used / total) * 100) : null);
|
||||
const sev = p => (p == null ? 'ok' : p >= CRIT ? 'crit' : p >= WARN ? 'warn' : 'ok');
|
||||
const worstOf = items => items.reduce(
|
||||
(w, x) => (x.status === 'crit' || w === 'crit') ? 'crit' : (x.status === 'warn' || w === 'warn') ? 'warn' : 'ok', 'ok');
|
||||
|
||||
// Pure: fold /nodes/*/disks/zfs + /cluster/resources(storage,vm) into the card shape.
|
||||
export function normalizeStorage(storageRes = [], vmRes = [], zfsByNode = {}) {
|
||||
// Imported ZFS pools (health + usage)
|
||||
const pools = [];
|
||||
for (const [node, list] of Object.entries(zfsByNode)) {
|
||||
for (const z of (list || [])) {
|
||||
const p = pct(z.alloc, z.size);
|
||||
pools.push({
|
||||
name: z.name, node, health: z.health, used: z.alloc, total: z.size, pct: p,
|
||||
status: z.health !== 'ONLINE' ? 'crit' : sev(p)
|
||||
});
|
||||
}
|
||||
}
|
||||
pools.sort((a, b) => a.name.localeCompare(b.name) || a.node.localeCompare(b.node));
|
||||
|
||||
// zfspool storages that are configured but NOT available = a pool that has dropped
|
||||
// out (or never imported). This is the donatello/leonardo signal.
|
||||
const down = storageRes
|
||||
.filter(s => s.plugintype === 'zfspool' && s.status !== 'available')
|
||||
.map(s => ({ name: s.storage, node: s.node, state: s.status || 'unavailable', status: 'crit' }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.node.localeCompare(b.node));
|
||||
|
||||
// Per-guest rootfs fill. LXC report disk/maxdisk; QEMU usually report disk=0
|
||||
// (no agent) so they're skipped rather than shown as 0%.
|
||||
const guests = vmRes
|
||||
.filter(v => v.type === 'lxc' && v.maxdisk > 0 && v.disk > 0)
|
||||
.map(v => {
|
||||
const p = pct(v.disk, v.maxdisk);
|
||||
return { vmid: v.vmid, name: v.name, node: v.node, used: v.disk, total: v.maxdisk, pct: p, status: sev(p) };
|
||||
})
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
const alerts = [
|
||||
...down.map(d => `${d.name} (${d.node}) ${d.state}`),
|
||||
...pools.filter(p => p.health !== 'ONLINE').map(p => `pool ${p.name} ${p.health}`),
|
||||
...guests.filter(g => g.status !== 'ok').map(g => `CT ${g.vmid} ${g.name} ${g.pct}%`)
|
||||
];
|
||||
|
||||
return { worst: worstOf([...pools, ...down, ...guests]), pools, down, guests, alerts };
|
||||
}
|
||||
|
||||
export async function storageHealth(opts = {}) {
|
||||
const cfg = {
|
||||
apiUrl: opts.apiUrl || process.env.PROXMOX_API_URL,
|
||||
token: opts.token || process.env.PROXMOX_RO_TOKEN || process.env.PROXMOX_API_TOKEN,
|
||||
fetchImpl: opts.fetchImpl || fetch
|
||||
};
|
||||
if (!cfg.apiUrl || !cfg.token) return { error: 'proxmox_not_configured', at: Date.now() };
|
||||
try {
|
||||
const [storageRes, vmRes, nodes] = await Promise.all([
|
||||
pveGet('/cluster/resources?type=storage', cfg),
|
||||
pveGet('/cluster/resources?type=vm', cfg),
|
||||
pveGet('/nodes', cfg)
|
||||
]);
|
||||
const zfsByNode = {};
|
||||
await Promise.all((nodes || [])
|
||||
.filter(n => n.status === 'online')
|
||||
.map(async n => { zfsByNode[n.node] = await pveGet(`/nodes/${n.node}/disks/zfs`, cfg).catch(() => []); }));
|
||||
return { ...normalizeStorage(storageRes, vmRes, zfsByNode), at: Date.now() };
|
||||
} catch (e) {
|
||||
return { error: String(e.message || e), at: Date.now() };
|
||||
}
|
||||
}
|
||||
14
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.6.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.16",
|
||||
"version": "2.6.5",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
@@ -965,6 +966,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.3.0",
|
||||
"version": "2.6.5",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"adm-zip": "^0.5.17",
|
||||
"bcrypt": "^6.0.0",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
|
||||
@@ -11,11 +11,14 @@ function token() { return localStorage.getItem(TOKEN_KEY) || ''; }
|
||||
|
||||
async function call(method, path, body) {
|
||||
const headers = { 'Authorization': 'Bearer ' + token() };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
// FormData bodies: let the browser set the multipart/form-data boundary
|
||||
// automatically — do NOT set Content-Type or JSON.stringify.
|
||||
const isFormData = body instanceof FormData;
|
||||
if (body !== undefined && !isFormData) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
body: body === undefined ? undefined : (isFormData ? body : JSON.stringify(body))
|
||||
});
|
||||
if (res.status === 401) { await promptForToken(); return call(method, path, body); }
|
||||
if (res.status === 204) return null;
|
||||
@@ -61,11 +64,14 @@ function promptForToken() {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
get: (p) => call('GET', p),
|
||||
post: (p, body) => call('POST', p, body ?? {}),
|
||||
put: (p, body) => call('PUT', p, body ?? {}),
|
||||
patch: (p, body) => call('PATCH', p, body ?? {}),
|
||||
del: (p) => call('DELETE', p),
|
||||
// POST a FormData body (multipart/form-data). Content-Type is omitted so
|
||||
// the browser appends the correct multipart boundary automatically.
|
||||
postForm: (p, fd) => call('POST', p, fd),
|
||||
setToken: (v) => localStorage.setItem(TOKEN_KEY, v),
|
||||
hasToken: () => !!token()
|
||||
};
|
||||
|
||||
20
public/icons/devices/camera.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [video, photo, aperture, camera, content, entertainment, multimedia, broadcast, audio]
|
||||
category: Media
|
||||
version: "1.0"
|
||||
unicode: "ea54"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
|
||||
<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
23
public/icons/devices/console.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
tags: [game, play, entertainment, console, joystick, joypad, controller, device, gamepad, hardware]
|
||||
category: Devices
|
||||
version: "1.68"
|
||||
unicode: "f1d2"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5h3.5a5 5 0 0 1 0 10h-5.5l-4.015 4.227a2.3 2.3 0 0 1 -3.923 -2.035l1.634 -8.173a5 5 0 0 1 4.904 -4.019h3.4" />
|
||||
<path d="M14 15l4.07 4.284a2.3 2.3 0 0 0 3.925 -2.023l-1.6 -8.232" />
|
||||
<path d="M8 9v2" />
|
||||
<path d="M7 10h2" />
|
||||
<path d="M14 10h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 629 B |
22
public/icons/devices/desktop.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [monitor, computer, imac, device, desktop, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea89"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10" />
|
||||
<path d="M7 20h10" />
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 528 B |
20
public/icons/devices/laptop.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [workstation, mac, notebook, portable, screen, computer, device, laptop, hardware, technology]
|
||||
category: Devices
|
||||
version: "1.2"
|
||||
unicode: "eb64"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 19l18 0" />
|
||||
<path d="M5 7a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1l0 -8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 482 B |
21
public/icons/devices/nas.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [storage, data, memory, database, repository, records, information, table, content, record]
|
||||
category: Database
|
||||
version: "1.0"
|
||||
unicode: "ea88"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 6a8 3 0 1 0 16 0a8 3 0 1 0 -16 0" />
|
||||
<path d="M4 6v6a8 3 0 0 0 16 0v-6" />
|
||||
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
21
public/icons/devices/phone.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [iphone, phone, smartphone, cellphone, device, mobile, hardware, technology, electronic, gadget]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8a"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14" />
|
||||
<path d="M11 4h2" />
|
||||
<path d="M12 17v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 505 B |
22
public/icons/devices/plug.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [electricity, charger, socket, connection, plug, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.6"
|
||||
unicode: "ebd9"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9.785 6l8.215 8.215l-2.054 2.054a5.81 5.81 0 1 1 -8.215 -8.215l2.054 -2.054" />
|
||||
<path d="M4 20l3.5 -3.5" />
|
||||
<path d="M15 4l-3.5 3.5" />
|
||||
<path d="M20 9l-3.5 3.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 551 B |
21
public/icons/devices/printer.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [fax, office, device, printer, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb0e"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
|
||||
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
|
||||
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 599 B |
24
public/icons/devices/router.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
tags: [wifi, device, wireless, signal, station, cast, router, hardware, technology, electronic]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb18"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 15a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -4" />
|
||||
<path d="M17 17l0 .01" />
|
||||
<path d="M13 17l0 .01" />
|
||||
<path d="M15 13l0 -2" />
|
||||
<path d="M11.75 8.75a4 4 0 0 1 6.5 0" />
|
||||
<path d="M8.5 6.5a8 8 0 0 1 13 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 617 B |
22
public/icons/devices/server.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
tags: [storage, hosting, www, server, hardware, technology, electronic, gadget, equipment]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "eb1f"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 7a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3" />
|
||||
<path d="M3 15a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v2a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -2" />
|
||||
<path d="M7 8l0 .01" />
|
||||
<path d="M7 16l0 .01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
21
public/icons/devices/speaker.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [voice, loud, microphone, loudspeaker, event, protest, speaker, shout, listen, speakerphone]
|
||||
category: Media
|
||||
version: "1.31"
|
||||
unicode: "ed61"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 8a3 3 0 0 1 0 6" />
|
||||
<path d="M10 8v11a1 1 0 0 1 -1 1h-1a1 1 0 0 1 -1 -1v-5" />
|
||||
<path d="M12 8l4.524 -3.77a.9 .9 0 0 1 1.476 .692v12.156a.9 .9 0 0 1 -1.476 .692l-4.524 -3.77h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1h8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
20
public/icons/devices/tablet.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [ipad, mobile, touchscreen, portable, device, tablet, hardware, technology, electronic, gadget]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8c"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16" />
|
||||
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 508 B |
20
public/icons/devices/tv.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
tags: [screen, display, movie, film, watch, audio, video, media, device, hardware]
|
||||
category: Devices
|
||||
version: "1.0"
|
||||
unicode: "ea8d"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 9a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2l0 -9" />
|
||||
<path d="M16 3l-4 4l-4 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 470 B |
21
public/icons/devices/unknown.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
category: System
|
||||
tags: [mystery, undefined, unclear, unidentified, uncertain, ambiguous, obscure, unseen, anonymous, unspecified]
|
||||
unicode: "fef4"
|
||||
version: "3.5"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 5a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2l0 -14" />
|
||||
<path d="M12 16v.01" />
|
||||
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 567 B |
21
public/icons/devices/watch.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
tags: [arm, hour, date, minutes, sec., timer, device, watch, hardware, technology]
|
||||
category: Devices
|
||||
version: "1.8"
|
||||
unicode: "ebf9"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#e8e6ed"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 9a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3v-6" />
|
||||
<path d="M9 18v3h6v-3" />
|
||||
<path d="M9 6v-3h6v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 489 B |
@@ -572,6 +572,18 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.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; }
|
||||
/* touch devices have no hover — keep the ✎ edit button always visible there */
|
||||
@media (hover: none) { .dv-edit-btn { opacity: .85; } }
|
||||
/* Little Blue service-tile edit affordance */
|
||||
.lb-tile-wrap { position: relative; }
|
||||
.lb-edit-btn { position: absolute; top: 5px; right: 5px; z-index: 5; background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; }
|
||||
.lb-tile-wrap:hover .lb-edit-btn { opacity: 1; }
|
||||
.lb-edit-btn:hover { color: var(--accent); border-color: var(--accent-dim); }
|
||||
@media (hover: none) { .lb-edit-btn { opacity: .85; } }
|
||||
.lb-edit { display: flex; flex-direction: column; gap: 4px; padding: 8px; }
|
||||
.lb-edit .dv-edit-name, .lb-edit .dv-edit-grp { width: 100%; margin: 0; }
|
||||
.lb-edit-btns { display: flex; gap: 4px; margin-top: 2px; }
|
||||
.lb-edit-btns button { font-size: 11px; padding: 2px 8px; }
|
||||
.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; }
|
||||
@@ -635,3 +647,26 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; }
|
||||
.sv-tray-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Storage card (sv-cluster container) — warn dot + capacity meter + subheader */
|
||||
.sv-cluster .status-warn .dot { background: var(--warn); box-shadow: 0 0 7px var(--warn); }
|
||||
.sv-cluster .ok { color: var(--ok); }
|
||||
.sv-cluster .bad { color: var(--bad); }
|
||||
.sv-cluster .st-meter { height: 3px; background: var(--accent-soft); border-radius: 2px; margin: 3px 0 9px; overflow: hidden; }
|
||||
.sv-cluster .st-fill { height: 100%; border-radius: 2px; }
|
||||
.sv-cluster .st-fill.ok { background: var(--ok); }
|
||||
.sv-cluster .st-fill.warn { background: var(--warn); }
|
||||
.sv-cluster .st-fill.bad { background: var(--bad); }
|
||||
.sv-cluster .sv-subhdr { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin: 11px 0 5px; font-family: var(--font-mono); }
|
||||
|
||||
.dv-icon { width: 30px; height: 30px; object-fit: contain; opacity: .95; }
|
||||
.dv-icon-fb { width: 30px; height: 30px; display: grid; place-items: center; font-size: 14px; color: var(--text); background: var(--panel-2, #1b1b22); border-radius: 4px; }
|
||||
.dv-seen { font-size: 11px; color: var(--muted, #8a8a94); }
|
||||
.icon-picker { border: 1px solid var(--border, #2a2a36); border-radius: 6px; padding: 6px; margin-top: 6px; max-width: 320px; }
|
||||
.ip-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
|
||||
.ip-tab.active { color: var(--accent, #ff4f2e); border-bottom: 1px solid var(--accent, #ff4f2e); }
|
||||
.ip-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.ip-icon { width: 40px; height: 40px; display: grid; place-items: center; background: transparent; border: 1px solid var(--border, #2a2a36); border-radius: 4px; cursor: pointer; }
|
||||
.ip-icon img { width: 28px; height: 28px; object-fit: contain; }
|
||||
.ip-set-hd, .isp-hd { font-size: 12px; margin: 6px 0 3px; text-transform: capitalize; }
|
||||
.isp-upload { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
|
||||
50
public/views/cards/backups.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// public/views/cards/backups.js — offsite DR backup status (Core-4 -> Farm/Won).
|
||||
// Fed by /usr/local/bin/offsite-backup.sh which POSTs each run to /api/backups.
|
||||
import { el, mount } from '../../dom.js';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
let body, timer;
|
||||
|
||||
const gb = b => (b == null ? '–'
|
||||
: b >= 1e12 ? (b / 1e12).toFixed(1) + 'T'
|
||||
: b >= 1e9 ? (b / 1e9).toFixed(1) + 'G'
|
||||
: Math.round(b / 1e6) + 'M');
|
||||
function ago(ts) {
|
||||
const s = Math.max(0, (Date.now() - Date.parse(ts)) / 1000);
|
||||
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||
return Math.floor(s / 86400) + 'd';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!body) return;
|
||||
try {
|
||||
const d = await api.get('/api/backups');
|
||||
const r = d.latest;
|
||||
if (!r) { mount(body, el('span', { class: 'muted' }, 'No offsite backups yet.')); return; }
|
||||
const stale = (Date.now() - Date.parse(r.ran_at)) > 8 * 86400000; // >8d overdue
|
||||
const status = (!r.ok || stale) ? 'bad' : 'ok';
|
||||
const kids = [];
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'Last run'),
|
||||
el('span', { class: 'cl-badge ' + status }, r.ok ? ago(r.ran_at) + ' ago' : 'FAILED')));
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'Pushed to Farm'), el('span', {}, gb(r.total_bytes))));
|
||||
for (const g of (r.guests || []))
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'CT ' + g.vmid + ' ' + g.name),
|
||||
el('span', { class: 'muted' }, gb(g.bytes))));
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'Farm free'), el('span', {}, gb(r.won_free_bytes))));
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'Schedule'), el('span', { class: 'muted' }, d.schedule || 'weekly')));
|
||||
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
|
||||
} catch { mount(body, el('span', { class: 'muted' }, 'Backups unavailable')); }
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'backups', title: 'Backups · offsite', size: 's',
|
||||
mount(e) { body = e; load(); },
|
||||
start() { timer = setInterval(load, 60000); },
|
||||
stop() { clearInterval(timer); body = null; }
|
||||
};
|
||||
62
public/views/cards/storage.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// public/views/cards/storage.js — Proxmox storage health: ZFS pools, dropped pools,
|
||||
// and per-container disk fill. Surfaces the two failure modes that have actually bitten
|
||||
// this homelab (a pool dropping off the SATA bus; a container rootfs filling up).
|
||||
import { el, mount } from '../../dom.js';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
let body, timer;
|
||||
|
||||
const gb = b => (b >= 1e12 ? (b / 1e12).toFixed(1) + 'T' : Math.round(b / 1e9) + 'G');
|
||||
const cls = s => (s === 'crit' ? 'bad' : s === 'warn' ? 'warn' : 'ok');
|
||||
const dotClass = s => 'status-' + (s === 'crit' ? 'down' : s === 'warn' ? 'warn' : 'ok');
|
||||
|
||||
function meterRow(label, value, p, status) {
|
||||
const wrap = el('div', { class: dotClass(status) });
|
||||
wrap.appendChild(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, el('span', { class: 'dot' }), label),
|
||||
el('span', { class: cls(status) }, value)));
|
||||
if (p != null) {
|
||||
wrap.appendChild(el('div', { class: 'st-meter' },
|
||||
el('div', { class: 'st-fill ' + cls(status), style: { width: Math.min(p, 100) + '%' } })));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!body) return;
|
||||
try {
|
||||
const s = await api.get('/api/storage');
|
||||
if (s.error) { mount(body, el('span', { class: 'muted' }, 'Storage: ' + s.error)); return; }
|
||||
|
||||
const kids = [];
|
||||
// overall badge
|
||||
kids.push(el('div', { class: 'sv-row' },
|
||||
el('span', { class: 'k' }, 'Status'),
|
||||
el('span', { class: 'cl-badge ' + (s.worst === 'ok' ? 'ok' : 'bad') },
|
||||
s.worst === 'ok' ? 'HEALTHY' : s.worst === 'warn' ? 'WATCH' : 'ATTENTION')));
|
||||
|
||||
// dropped pools first (most urgent — e.g. donatello/leonardo off the bus)
|
||||
for (const d of (s.down || []))
|
||||
kids.push(meterRow(d.name + ' · ' + d.node, '⚠ ' + String(d.state).toUpperCase(), null, 'crit'));
|
||||
|
||||
// imported ZFS pools
|
||||
for (const p of (s.pools || []))
|
||||
kids.push(meterRow(p.name + ' · ' + p.node,
|
||||
(p.health !== 'ONLINE' ? p.health + ' · ' : '') + (p.pct ?? '–') + '%', p.pct, p.status));
|
||||
|
||||
// container disk fill (top few by %)
|
||||
const top = (s.guests || []).slice(0, 5);
|
||||
if (top.length) kids.push(el('div', { class: 'sv-subhdr' }, 'Container disk'));
|
||||
for (const g of top)
|
||||
kids.push(meterRow('CT ' + g.vmid + ' ' + g.name, g.pct + '% · ' + gb(g.used) + '/' + gb(g.total), g.pct, g.status));
|
||||
|
||||
mount(body, el('div', { class: 'sv-cluster' }, ...kids));
|
||||
} catch { mount(body, el('span', { class: 'muted' }, 'Storage unavailable')); }
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'storage', title: 'Storage · capacity', size: 'm',
|
||||
mount(e) { body = e; load(); },
|
||||
start() { timer = setInterval(load, 30000); },
|
||||
stop() { clearInterval(timer); body = null; }
|
||||
};
|
||||
@@ -3,6 +3,8 @@
|
||||
// newly-seen devices. Kept SEPARATE from Little Blue's homelab-service band.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { resolveIcon, relativeTime, autoDefaultIcon } from './icon_util.js';
|
||||
import { iconPicker } from './icon_picker.js';
|
||||
|
||||
let host;
|
||||
const GROUPS = ['Smart Home', 'Entertainment', 'Personal', 'Network', 'Flagged'];
|
||||
@@ -13,26 +15,50 @@ function tile(d) {
|
||||
clear(t);
|
||||
const edit = el('button', { class: 'dv-edit-btn', title: 'Edit device' }, '✎');
|
||||
edit.onclick = editMode;
|
||||
mount(t,
|
||||
const ref = d.icon || autoDefaultIcon(d.grp);
|
||||
const src = resolveIcon(ref);
|
||||
const img = el('img', { class: 'dv-icon', src, alt: '' });
|
||||
img.onerror = () => {
|
||||
if (src && src.endsWith('.svg')) { img.src = src.replace(/\.svg$/, '.png'); return; }
|
||||
img.replaceWith(el('div', { class: 'dv-icon-fb' }, (d.name?.[0] || '?').toUpperCase()));
|
||||
};
|
||||
const seen = d.present === false && d.last_seen
|
||||
? el('span', { class: 'dv-seen' }, 'seen ' + relativeTime(d.last_seen)) : null;
|
||||
mount(t, img,
|
||||
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' : '')),
|
||||
seen,
|
||||
d.mac ? edit : null);
|
||||
}
|
||||
function editMode() {
|
||||
clear(t);
|
||||
let chosenIcon = d.icon || null;
|
||||
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 pickerWrap = el('div', { class: 'dv-picker-wrap' });
|
||||
pickerWrap.style.display = 'none';
|
||||
const iconBtn = el('button', { class: 'ghost' }, 'Icon');
|
||||
iconBtn.onclick = () => {
|
||||
if (pickerWrap.style.display === 'none') {
|
||||
clear(pickerWrap);
|
||||
pickerWrap.append(iconPicker(chosenIcon, ref => { chosenIcon = ref; iconBtn.textContent = 'Icon ✓'; pickerWrap.style.display = 'none'; }));
|
||||
pickerWrap.style.display = 'block';
|
||||
} else pickerWrap.style.display = 'none';
|
||||
};
|
||||
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(); };
|
||||
save.onclick = async () => {
|
||||
await api.patch('/api/devices/' + d.mac, { name: nameI.value.trim() || null, grp: grpS.value, icon: chosenIcon });
|
||||
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);
|
||||
mount(t, el('span', { class: 'dv-mac' }, d.mac), nameI, grpS, iconBtn, save, del, cancel, pickerWrap);
|
||||
}
|
||||
view();
|
||||
return t;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { serviceTile } from '../components/service_tile.js';
|
||||
import { isRemoteHost } from './service_url.js';
|
||||
|
||||
const TITLE = { agents: 'Agents', infrastructure: 'Infrastructure', media: 'Media', other: 'Other' };
|
||||
const CATS = ['agents', 'infrastructure', 'media', 'other'];
|
||||
let host, timer, scanning = false;
|
||||
|
||||
async function promote(id) {
|
||||
@@ -17,6 +18,36 @@ function scan() {
|
||||
setTimeout(() => { scanning = false; load(); }, 30000); // LAN sweep ~25s
|
||||
}
|
||||
|
||||
// Inline edit form for a service (name / category / url / icon) — PATCH or DELETE.
|
||||
function editForm(s) {
|
||||
const nameI = el('input', { class: 'dv-edit-name', value: s.name || '', placeholder: 'name' });
|
||||
const catS = el('select', { class: 'dv-edit-grp' }, ...CATS.map(c => el('option', { value: c }, TITLE[c])));
|
||||
catS.value = s.category || 'other';
|
||||
const urlI = el('input', { class: 'dv-edit-name', value: s.url || '', placeholder: 'http://host:port' });
|
||||
const iconI = el('input', { class: 'dv-edit-name', value: s.icon || '', placeholder: 'icon slug e.g. plex' });
|
||||
const save = el('button', { class: 'dv-add' }, 'Save');
|
||||
save.onclick = async () => {
|
||||
const patch = { name: nameI.value.trim(), category: catS.value, url: urlI.value.trim() };
|
||||
const ic = iconI.value.trim(); if (ic) patch.icon = ic;
|
||||
try { await api.patch('/api/health/services/' + s.id, patch); load(); } catch { /* */ }
|
||||
};
|
||||
const del = el('button', { class: 'ghost dv-ignore' }, 'Delete');
|
||||
del.onclick = async () => { try { await api.del('/api/health/services/' + s.id); load(); } catch { /* */ } };
|
||||
const cancel = el('button', { class: 'ghost' }, 'Cancel');
|
||||
cancel.onclick = load;
|
||||
return el('div', { class: 'tile lb-edit' }, nameI, catS, urlI, iconI,
|
||||
el('div', { class: 'lb-edit-btns' }, save, del, cancel));
|
||||
}
|
||||
|
||||
// A service tile wrapped with an ✎ edit button that swaps to the edit form.
|
||||
function tileWithEdit(s, remote) {
|
||||
const wrap = el('div', { class: 'lb-tile-wrap' });
|
||||
const edit = el('button', { class: 'lb-edit-btn', title: 'Edit service' }, '✎');
|
||||
edit.onclick = (e) => { e.preventDefault(); e.stopPropagation(); mount(wrap, editForm(s)); };
|
||||
mount(wrap, serviceTile(s, remote), edit);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Owner-only; returns a section element or null (skipped for non-owner / none).
|
||||
async function discoveredSection() {
|
||||
let cand;
|
||||
@@ -30,8 +61,8 @@ async function discoveredSection() {
|
||||
el('div', { class: 'tiles' }, cand.map(c =>
|
||||
el('div', { class: 'tile disc' },
|
||||
el('div', { class: 'tile-main' },
|
||||
el('div', { class: 'tile-nm' }, c.name),
|
||||
el('div', { class: 'tile-host' }, c.url)),
|
||||
el('div', { class: 'tile-nm' }, c.device || c.name),
|
||||
el('div', { class: 'tile-host' }, c.device ? `${c.name} · ${c.url}` : c.url)),
|
||||
el('button', { class: 'disc-add', title: 'Add to the band', onclick: () => promote(c.id) }, '+')))));
|
||||
}
|
||||
|
||||
@@ -46,7 +77,7 @@ async function load() {
|
||||
el('span', { class: 'gname' }, TITLE[g.category] || g.category),
|
||||
el('span', { class: 'gcount' }, `${g.healthy}/${g.total} healthy`),
|
||||
el('span', { class: 'line' })),
|
||||
el('div', { class: 'tiles' }, g.services.map(s => serviceTile(s, remote)))));
|
||||
el('div', { class: 'tiles' }, g.services.map(s => tileWithEdit(s, remote)))));
|
||||
const disc = await discoveredSection();
|
||||
mount(host,
|
||||
el('div', { class: 'lbwrap' }, littleblueAvatar(),
|
||||
|
||||
56
public/views/icon_picker.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// public/views/icon_picker.js — inline picker with Type + Brand tabs.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
// onPick(ref) called with 'set:<set>:<name>' or 'brand:<slug>'. Returns an element.
|
||||
export function iconPicker(currentRef, onPick) {
|
||||
const box = el('div', { class: 'icon-picker' });
|
||||
const tabs = el('div', { class: 'ip-tabs' });
|
||||
const body = el('div', { class: 'ip-body' });
|
||||
const typeTab = el('button', { class: 'ip-tab active' }, 'Type');
|
||||
const brandTab = el('button', { class: 'ip-tab' }, 'Brand');
|
||||
typeTab.onclick = () => { typeTab.classList.add('active'); brandTab.classList.remove('active'); showType(); };
|
||||
brandTab.onclick = () => { brandTab.classList.add('active'); typeTab.classList.remove('active'); showBrand(); };
|
||||
|
||||
async function showType() {
|
||||
clear(body);
|
||||
body.append(el('div', { class: 'muted' }, 'Loading…'));
|
||||
let list = [];
|
||||
try { list = await api.get('/api/icon-sets'); } catch { /* ignore */ }
|
||||
clear(body);
|
||||
for (const s of list) {
|
||||
const grid = el('div', { class: 'ip-grid' }, s.icons.map(file => {
|
||||
const name = file.replace(/\.[a-z]+$/, '');
|
||||
const ref = `set:${s.set}:${name}`;
|
||||
const b = el('button', { class: 'ip-icon', title: name },
|
||||
el('img', { src: `/api/icon-sets/${s.set}/${file}` }));
|
||||
b.onclick = () => onPick(ref);
|
||||
return b;
|
||||
}));
|
||||
body.append(el('div', { class: 'ip-set' },
|
||||
el('div', { class: 'ip-set-hd' }, s.set + (s.readonly ? '' : ' ·')),
|
||||
grid));
|
||||
}
|
||||
}
|
||||
|
||||
function showBrand() {
|
||||
clear(body);
|
||||
const inp = el('input', { class: 'dv-edit-name', placeholder: 'brand slug e.g. apple, google-nest' });
|
||||
const prev = el('div', { class: 'ip-grid' });
|
||||
inp.oninput = () => {
|
||||
const slug = inp.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
clear(prev);
|
||||
if (!slug) return;
|
||||
const b = el('button', { class: 'ip-icon' },
|
||||
el('img', { src: `/api/icons/${slug}.png` }));
|
||||
b.onclick = () => onPick(`brand:${slug}`);
|
||||
prev.append(b);
|
||||
};
|
||||
body.append(inp, prev);
|
||||
}
|
||||
|
||||
mount(tabs, typeTab, brandTab);
|
||||
mount(box, tabs, body);
|
||||
showType();
|
||||
return box;
|
||||
}
|
||||
70
public/views/icon_sets_panel.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Icon sets management panel — list, upload, delete custom icon sets.
|
||||
import { el, mount, clear } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
export function iconSetsPanel() {
|
||||
const root = el('div', { class: 'icon-sets-panel' });
|
||||
|
||||
async function refresh() {
|
||||
clear(root);
|
||||
let list = [];
|
||||
try { list = await api.get('/api/icon-sets'); } catch {
|
||||
root.appendChild(el('div', { class: 'muted' }, 'unavailable'));
|
||||
return;
|
||||
}
|
||||
for (const s of list) {
|
||||
const grid = el('div', { class: 'ip-grid' },
|
||||
s.icons.map(f =>
|
||||
el('div', { class: 'ip-icon' },
|
||||
el('img', { src: `/api/icon-sets/${s.set}/${f}`, title: f })
|
||||
)
|
||||
)
|
||||
);
|
||||
const head = el('div', { class: 'isp-hd' },
|
||||
el('b', {}, s.set),
|
||||
el('span', { class: 'muted' }, ` ${s.icons.length}`)
|
||||
);
|
||||
if (!s.readonly) {
|
||||
const del = el('button', { class: 'ghost' }, 'Delete');
|
||||
del.addEventListener('click', async () => {
|
||||
await api.del('/api/icon-sets/' + s.set);
|
||||
refresh();
|
||||
});
|
||||
head.appendChild(del);
|
||||
}
|
||||
root.appendChild(el('div', { class: 'isp-set' }, head, grid));
|
||||
}
|
||||
root.appendChild(uploadForm(refresh));
|
||||
}
|
||||
|
||||
refresh();
|
||||
return root;
|
||||
}
|
||||
|
||||
function uploadForm(onDone) {
|
||||
const setI = el('input', { class: 'dv-edit-name', placeholder: 'new set name (a-z0-9-)' });
|
||||
const fileI = el('input', { type: 'file', accept: '.svg,.png,.zip', multiple: true });
|
||||
const urlI = el('input', { class: 'dv-edit-name', placeholder: 'or ingest from URL (image or .zip)' });
|
||||
const err = el('span', { class: 'muted', style: { fontSize: '11px' } }, '');
|
||||
const up = el('button', { class: 'dv-add' }, 'Upload');
|
||||
|
||||
up.addEventListener('click', async () => {
|
||||
const set = setI.value.trim().toLowerCase();
|
||||
if (!/^[a-z0-9-]+$/.test(set)) { err.textContent = 'set name: a-z 0-9 - only'; return; }
|
||||
if (!fileI.files.length && !urlI.value.trim()) { err.textContent = 'pick files or a URL'; return; }
|
||||
const fd = new FormData();
|
||||
for (const f of fileI.files) fd.append('files', f);
|
||||
if (urlI.value.trim()) fd.append('url', urlI.value.trim());
|
||||
up.textContent = 'Uploading…'; up.disabled = true;
|
||||
try {
|
||||
await api.postForm('/api/icon-sets/' + set, fd);
|
||||
onDone();
|
||||
} catch {
|
||||
err.textContent = 'upload failed';
|
||||
up.textContent = 'Upload';
|
||||
up.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return el('div', { class: 'isp-upload' }, setI, fileI, urlI, up, err);
|
||||
}
|
||||
24
public/views/icon_util.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// public/views/icon_util.js — pure helpers (no DOM), unit-tested.
|
||||
const GROUP_DEFAULT = {
|
||||
Network: 'router', Entertainment: 'tv', 'Smart Home': 'plug', Personal: 'phone'
|
||||
};
|
||||
export function autoDefaultIcon(grp) {
|
||||
return `set:devices:${GROUP_DEFAULT[grp] || 'unknown'}`;
|
||||
}
|
||||
// Note: bundled 'devices' icons are .svg; brand icons are served .png by the proxy.
|
||||
export function resolveIcon(ref) {
|
||||
if (typeof ref !== 'string') return null;
|
||||
let m = ref.match(/^set:([a-z0-9-]+):([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icon-sets/${m[1]}/${m[2]}.svg`;
|
||||
m = ref.match(/^brand:([a-z0-9-]+)$/);
|
||||
if (m) return `/api/icons/${m[1]}.png`;
|
||||
return null;
|
||||
}
|
||||
export function relativeTime(iso, now = Date.now()) {
|
||||
const t = typeof iso === 'number' ? iso : Date.parse(iso);
|
||||
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
@@ -14,8 +14,10 @@ import search from './cards/search.js';
|
||||
import speedtest from './cards/speedtest.js';
|
||||
import aiUsage from './cards/ai_usage.js';
|
||||
import cluster from './cards/cluster.js';
|
||||
import storage from './cards/storage.js';
|
||||
import backups from './cards/backups.js';
|
||||
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, jobs, inbox, search, speedtest, aiUsage];
|
||||
const CARD_MODULES = [clock, weather, hostPerf, cluster, storage, backups, jobs, inbox, search, speedtest, aiUsage];
|
||||
const BY_ID = new Map(CARD_MODULES.map(d => [d.id, d]));
|
||||
|
||||
let active = []; // mounted cards needing stop()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// #/settings — API tokens, agents, and a placeholder for Orthos Mode.
|
||||
import { el, mount } from '../dom.js';
|
||||
import { api } from '../api.js';
|
||||
import { iconSetsPanel } from './icon_sets_panel.js';
|
||||
|
||||
function section(title, sub, bodyEl) {
|
||||
return el('div', { class: 'card settings-card' },
|
||||
@@ -99,10 +100,29 @@ async function renderAgents(c) {
|
||||
export async function render(main) {
|
||||
const tokensBody = el('div', { class: 'settings-body' });
|
||||
const agentsBody = el('div', { class: 'settings-body' });
|
||||
|
||||
// Icon sets — collapsible; panel is lazy-created on first expand so
|
||||
// /api/icon-sets is not fetched while the section is collapsed.
|
||||
let isPanel = null;
|
||||
const iconSetsWrap = el('div', { class: 'settings-body' });
|
||||
const isToggle = el('button', { class: 'ghost' }, '▸ Icon sets');
|
||||
isToggle.addEventListener('click', () => {
|
||||
if (!isPanel) {
|
||||
// First expand: create and append the panel.
|
||||
isPanel = iconSetsPanel();
|
||||
iconSetsWrap.appendChild(isPanel);
|
||||
}
|
||||
const open = isPanel.style.display !== 'none';
|
||||
isPanel.style.display = open ? 'none' : 'block';
|
||||
isToggle.textContent = (open ? '▸' : '▾') + ' Icon sets';
|
||||
});
|
||||
iconSetsWrap.appendChild(isToggle);
|
||||
|
||||
mount(main,
|
||||
el('h1', { class: 'view-h1' }, '◆ Settings'),
|
||||
section('API Tokens', 'Bearer tokens for agents (e.g. the MCP external-research agent). The secret is shown once at creation.', tokensBody),
|
||||
section('Agents', 'Seeded Cradle agents and what each is allowed to do.', agentsBody),
|
||||
section('Icon Sets', 'Upload or delete custom icon packs for device and service icons.', iconSetsWrap),
|
||||
section('Orthos Mode', 'Local-first answering — Orthos answers first, Claude escalates when needed.',
|
||||
el('div', { class: 'muted' }, 'Paused for a future project (arrives with the local-agent layer).'))
|
||||
);
|
||||
|
||||
11
server.js
@@ -7,14 +7,19 @@ import * as queue from './lib/jobs/queue.js';
|
||||
import { registerWorkers } from './lib/jobs/index.js';
|
||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||
import { router as iconsRouter } from './lib/api/routes/icons.js';
|
||||
import { router as iconSetsRouter } from './lib/api/routes/icon_sets.js';
|
||||
import { router as devicesRouter } from './lib/api/routes/devices.js';
|
||||
import { startCron } from './lib/cron/index.js';
|
||||
import { seedFromConfig } from './lib/health/registry.js';
|
||||
import { mcpAuth } from './lib/api/middleware/mcp_auth.js';
|
||||
import { handleMcp } from './lib/mcp/http.js';
|
||||
import httpProxy from 'http-proxy';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const VERSION = '2.3.0';
|
||||
// Read the version from package.json so a deploy never serves a stale /health
|
||||
// version (the old hardcoded const had to be bumped by hand and caused the
|
||||
// health-gated deploy to roll back 3x when forgotten).
|
||||
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url))).version;
|
||||
|
||||
// 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
|
||||
@@ -53,6 +58,10 @@ export function createApp() {
|
||||
// slugs are sanitized to [a-z0-9-] to prevent path traversal.
|
||||
app.use('/api/icons', iconsRouter);
|
||||
|
||||
// /api/icon-sets/* — GET routes are open (same <img> reason as above);
|
||||
// POST/DELETE are protected by requireOwner inside the router.
|
||||
app.use('/api/icon-sets', iconSetsRouter);
|
||||
|
||||
// /api/devices — band data is public (like the static devices.json it replaces);
|
||||
// discovered/edit/scan sub-routes use requireOwner (401/403) internally.
|
||||
app.use('/api/devices', devicesRouter);
|
||||
|
||||
21
tests/api/backups.test.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ingest } from '../../lib/api/routes/backups.js';
|
||||
|
||||
describe('backups ingest schema', () => {
|
||||
it('accepts a valid run', () => {
|
||||
const r = ingest.safeParse({
|
||||
ok: true, total_bytes: 2400000000, won_free_bytes: 33000000000,
|
||||
guests: [{ vmid: 310, name: 'void-db', bytes: 518000000 }], duration_sec: 950
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
it('accepts an empty body (all fields optional)', () => {
|
||||
expect(ingest.safeParse({}).success).toBe(true);
|
||||
});
|
||||
it('rejects negative bytes', () => {
|
||||
expect(ingest.safeParse({ total_bytes: -5 }).success).toBe(false);
|
||||
});
|
||||
it('rejects malformed guests', () => {
|
||||
expect(ingest.safeParse({ guests: [{ vmid: 1 }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
72
tests/api/icon_sets.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// tests/api/icon_sets.test.js
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../../server.js';
|
||||
import * as sets from '../../lib/icons/sets.js';
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
|
||||
let app;
|
||||
let setsDir;
|
||||
let bundledDir;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.OWNER_TOKEN = 'test-token';
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up fresh temp dirs with a fake bundled device icon so the bundled set is non-empty.
|
||||
setsDir = mkdtempSync(path.join(tmpdir(), 'iconsets-'));
|
||||
bundledDir = path.join(setsDir, '__bundled');
|
||||
mkdirSync(bundledDir, { recursive: true });
|
||||
writeFileSync(path.join(bundledDir, 'router.svg'), '<svg><path/></svg>');
|
||||
sets._setDirs({ setsDir, bundledDir });
|
||||
});
|
||||
|
||||
const owner = r => r.set('Authorization', 'Bearer test-token');
|
||||
|
||||
describe('GET /api/icon-sets', () => {
|
||||
it('returns 200 with an array including the bundled devices set', async () => {
|
||||
const res = await request(app).get('/api/icon-sets');
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
const dev = res.body.find(s => s.set === 'devices');
|
||||
expect(dev).toBeDefined();
|
||||
expect(dev.readonly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/icon-sets/:set', () => {
|
||||
it('returns 401 (not 500) without auth', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/icon-sets/mytest')
|
||||
.attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 and uploads the icon with owner auth', async () => {
|
||||
const res = await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.set).toBe('mytest');
|
||||
expect(res.body.icons).toContain('router.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/icon-sets/:set/:file', () => {
|
||||
it('serves a previously uploaded icon with image/png content-type', async () => {
|
||||
// Upload first
|
||||
await owner(
|
||||
request(app).post('/api/icon-sets/mytest')
|
||||
).attach('files', PNG, { filename: 'router.png', contentType: 'image/png' });
|
||||
|
||||
const res = await request(app).get('/api/icon-sets/mytest/router.png');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/png');
|
||||
});
|
||||
});
|
||||
15
tests/icons/devices_icon.test.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { iconRef } from '../../lib/api/routes/devices.js';
|
||||
|
||||
describe('icon ref validation', () => {
|
||||
it('accepts set + brand refs and null', () => {
|
||||
expect(iconRef.safeParse('set:devices:router').success).toBe(true);
|
||||
expect(iconRef.safeParse('brand:apple').success).toBe(true);
|
||||
expect(iconRef.safeParse(null).success).toBe(true);
|
||||
});
|
||||
it('rejects junk', () => {
|
||||
expect(iconRef.safeParse('set:bad').success).toBe(false);
|
||||
expect(iconRef.safeParse('javascript:alert').success).toBe(false);
|
||||
});
|
||||
});
|
||||
79
tests/icons/ingest.test.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import AdmZip from 'adm-zip';
|
||||
import { processFile, unpackZip, fetchUrl, isBlockedAddress, MAX_FILE } from '../../lib/icons/ingest.js';
|
||||
|
||||
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a, 0,0,0,0]);
|
||||
|
||||
describe('processFile', () => {
|
||||
it('slugifies name, keeps png', () => {
|
||||
const r = processFile({ name: 'My Router.png', buffer: PNG });
|
||||
expect(r.name).toBe('my-router.png');
|
||||
expect(r.buffer).toBe(PNG);
|
||||
});
|
||||
it('sanitizes svg', () => {
|
||||
const r = processFile({ name: 'x.svg', buffer: Buffer.from('<svg><script>1</script><path/></svg>') });
|
||||
expect(r.buffer.toString()).not.toMatch(/script/i);
|
||||
});
|
||||
it('rejects non-image extension', () => {
|
||||
expect(() => processFile({ name: 'x.exe', buffer: PNG })).toThrow();
|
||||
});
|
||||
it('rejects oversize', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.alloc(MAX_FILE + 1, 1) })).toThrow();
|
||||
});
|
||||
it('rejects png with bad magic', () => {
|
||||
expect(() => processFile({ name: 'x.png', buffer: Buffer.from('not a png') })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpackZip', () => {
|
||||
it('extracts images, skips non-image junk', () => {
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('notes.txt', Buffer.from('hi'));
|
||||
const out = unpackZip(z.toBuffer());
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
|
||||
it('skips path-traversal entries', () => {
|
||||
// adm-zip's addFile() sanitizes '../' at write time (zipnamefix), so it
|
||||
// can't produce a real traversal entry. Build one by mutating the entry
|
||||
// name at the raw level *after* addFile — this survives serialization and
|
||||
// stores '../evil.png' verbatim in the zip bytes.
|
||||
const z = new AdmZip();
|
||||
z.addFile('a.png', PNG);
|
||||
z.addFile('placeholder.png', PNG);
|
||||
const entries = z.getEntries();
|
||||
entries[1].entryName = '../evil.png';
|
||||
const buf = z.toBuffer();
|
||||
// Sanity check: the traversal entry name really is in the serialized bytes.
|
||||
expect(buf.includes(Buffer.from('../evil.png'))).toBe(true);
|
||||
const out = unpackZip(buf);
|
||||
expect(out.map(f => f.name)).toEqual(['a.png']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isBlockedAddress', () => {
|
||||
it('blocks loopback IPv4', () => { expect(isBlockedAddress('127.0.0.1')).toBe(true); });
|
||||
it('blocks private 10/8', () => { expect(isBlockedAddress('10.1.2.3')).toBe(true); });
|
||||
it('blocks private 192.168/16', () => { expect(isBlockedAddress('192.168.0.1')).toBe(true); });
|
||||
it('blocks link-local 169.254/16', () => { expect(isBlockedAddress('169.254.1.1')).toBe(true); });
|
||||
it('blocks 0.0.0.0', () => { expect(isBlockedAddress('0.0.0.0')).toBe(true); });
|
||||
it('blocks IPv6 loopback ::1', () => { expect(isBlockedAddress('::1')).toBe(true); });
|
||||
it('blocks IPv6 ULA fc00::1', () => { expect(isBlockedAddress('fc00::1')).toBe(true); });
|
||||
it('allows public 8.8.8.8', () => { expect(isBlockedAddress('8.8.8.8')).toBe(false); });
|
||||
it('allows public 1.1.1.1', () => { expect(isBlockedAddress('1.1.1.1')).toBe(false); });
|
||||
});
|
||||
|
||||
describe('fetchUrl', () => {
|
||||
it('rejects non-http schemes', async () => {
|
||||
await expect(fetchUrl('file:///etc/passwd')).rejects.toThrow();
|
||||
});
|
||||
it('rejects localhost/private hosts', async () => {
|
||||
await expect(fetchUrl('http://127.0.0.1/x.png')).rejects.toThrow();
|
||||
});
|
||||
it('fetches via injected fetcher', async () => {
|
||||
const fake = async () => ({ ok: true, arrayBuffer: async () => PNG.buffer.slice(PNG.byteOffset, PNG.byteOffset + PNG.length), headers: new Map([['content-type','image/png']]) });
|
||||
const r = await fetchUrl('https://example.com/x.png', { fetcher: fake });
|
||||
expect(Buffer.isBuffer(r.buffer)).toBe(true);
|
||||
});
|
||||
});
|
||||
29
tests/icons/sanitize.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sanitizeSvg } from '../../lib/icons/sanitize.js';
|
||||
|
||||
describe('sanitizeSvg', () => {
|
||||
it('strips <script> tags', () => {
|
||||
const out = sanitizeSvg('<svg><script>alert(1)</script><path d="M0 0"/></svg>');
|
||||
expect(out).not.toMatch(/script/i);
|
||||
expect(out).toMatch(/<path/);
|
||||
});
|
||||
it('strips on* event handlers', () => {
|
||||
const out = sanitizeSvg('<svg onload="x()"><rect onclick="y()"/></svg>');
|
||||
expect(out).not.toMatch(/onload|onclick/i);
|
||||
});
|
||||
it('strips unquoted on* handlers', () => {
|
||||
const out = sanitizeSvg('<svg onload=alert(1)><rect onclick=go()/></svg>');
|
||||
expect(out).not.toMatch(/onload|onclick/i);
|
||||
});
|
||||
it('neutralizes javascript: hrefs', () => {
|
||||
const out = sanitizeSvg('<svg><a href="javascript:alert(1)">x</a></svg>');
|
||||
expect(out).not.toMatch(/javascript:/i);
|
||||
});
|
||||
it('drops <foreignObject>', () => {
|
||||
const out = sanitizeSvg('<svg><foreignObject><body>x</body></foreignObject></svg>');
|
||||
expect(out).not.toMatch(/foreignObject/i);
|
||||
});
|
||||
it('accepts a Buffer', () => {
|
||||
expect(sanitizeSvg(Buffer.from('<svg><path/></svg>'))).toMatch(/<svg/);
|
||||
});
|
||||
});
|
||||
37
tests/icons/sets.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import * as sets from '../../lib/icons/sets.js';
|
||||
|
||||
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
|
||||
let dir;
|
||||
beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'iconsets-')); sets._setDirs({ setsDir: dir, bundledDir: path.join(dir, '__bundled') }); mkdirSync(path.join(dir, '__bundled'), { recursive: true }); writeFileSync(path.join(dir, '__bundled', 'router.svg'), '<svg><path/></svg>'); });
|
||||
|
||||
describe('sets store', () => {
|
||||
it('lists the read-only bundled set', async () => {
|
||||
const list = await sets.listSets();
|
||||
const dev = list.find(s => s.set === 'devices');
|
||||
expect(dev.readonly).toBe(true);
|
||||
expect(dev.icons).toContain('router.svg');
|
||||
});
|
||||
it('writes + lists an uploaded set', async () => {
|
||||
await sets.writeIcon('mine', 'nas.png', PNG);
|
||||
const mine = (await sets.listSets()).find(s => s.set === 'mine');
|
||||
expect(mine.readonly).toBe(false);
|
||||
expect(mine.icons).toContain('nas.png');
|
||||
});
|
||||
it('refuses to write the reserved bundled set', async () => {
|
||||
await expect(sets.writeIcon('devices', 'x.png', PNG)).rejects.toThrow();
|
||||
});
|
||||
it('deletes an uploaded set, not the bundled one', async () => {
|
||||
await sets.writeIcon('mine', 'a.png', PNG);
|
||||
await sets.deleteSet('mine');
|
||||
expect((await sets.listSets()).find(s => s.set === 'mine')).toBeUndefined();
|
||||
await expect(sets.deleteSet('devices')).rejects.toThrow();
|
||||
});
|
||||
it('rejects bad slugs (traversal)', async () => {
|
||||
await expect(sets.writeIcon('../x', 'a.png', PNG)).rejects.toThrow();
|
||||
expect(() => sets.iconPath('mine', '../../etc/passwd')).toThrow();
|
||||
});
|
||||
});
|
||||
70
tests/proxmox/storage.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeStorage, storageHealth } from '../../lib/proxmox/storage.js';
|
||||
|
||||
// Fixtures mirror real PVE payload shapes from this cluster.
|
||||
const STORAGE = [
|
||||
{ storage: 'localzfs', node: 'z', status: 'available', plugintype: 'zfspool', disk: 37e9, maxdisk: 516e9 },
|
||||
{ storage: 'donatello-vm', node: 'z', status: 'unknown', plugintype: 'zfspool', disk: 0, maxdisk: 0 },
|
||||
{ storage: 'leonardo-vm', node: 'z', status: 'unknown', plugintype: 'zfspool', disk: 0, maxdisk: 0 },
|
||||
{ storage: 'local', node: 'z', status: 'available', plugintype: 'dir', disk: 1e9, maxdisk: 100e9 }
|
||||
];
|
||||
const VMS = [
|
||||
{ vmid: 100, name: 'mediastack', type: 'lxc', node: 'z', disk: 60e9, maxdisk: 63e9, status: 'running' }, // 95%
|
||||
{ vmid: 311, name: 'void-app', type: 'lxc', node: 'z', disk: 4e9, maxdisk: 16e9, status: 'running' }, // 25%
|
||||
{ vmid: 200, name: 'OpenClaw', type: 'qemu', node: 'z', disk: 0, maxdisk: 32e9, status: 'running' } // skipped (qemu/0)
|
||||
];
|
||||
const ZFS = { z: [{ name: 'localzfs', health: 'ONLINE', alloc: 37e9, size: 516e9, frag: 6 }] };
|
||||
|
||||
describe('normalizeStorage', () => {
|
||||
it('flags a dropped zfspool, a hot container, and rolls up worst=crit', () => {
|
||||
const r = normalizeStorage(STORAGE, VMS, ZFS);
|
||||
// dropped pools (donatello/leonardo) surface in `down`
|
||||
expect(r.down.map(d => d.name).sort()).toEqual(['donatello-vm', 'leonardo-vm']);
|
||||
expect(r.down.every(d => d.status === 'crit')).toBe(true);
|
||||
// imported pool present + healthy
|
||||
expect(r.pools).toHaveLength(1);
|
||||
expect(r.pools[0].name).toBe('localzfs');
|
||||
// guests: qemu/0 skipped, sorted desc, CT100 at 95% is crit
|
||||
expect(r.guests.map(g => g.vmid)).toEqual([100, 311]);
|
||||
expect(r.guests[0].pct).toBe(95);
|
||||
expect(r.guests[0].status).toBe('crit');
|
||||
expect(r.worst).toBe('crit');
|
||||
expect(r.alerts.some(a => a.includes('donatello-vm'))).toBe(true);
|
||||
expect(r.alerts.some(a => a.includes('CT 100'))).toBe(true);
|
||||
});
|
||||
|
||||
it('all-healthy rolls up to ok', () => {
|
||||
const r = normalizeStorage(
|
||||
[{ storage: 'localzfs', node: 'z', status: 'available', plugintype: 'zfspool' }],
|
||||
[{ vmid: 311, name: 'void-app', type: 'lxc', node: 'z', disk: 4e9, maxdisk: 16e9 }],
|
||||
{ z: [{ name: 'localzfs', health: 'ONLINE', alloc: 37e9, size: 516e9 }] }
|
||||
);
|
||||
expect(r.worst).toBe('ok');
|
||||
expect(r.down).toHaveLength(0);
|
||||
expect(r.alerts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageHealth', () => {
|
||||
it('returns proxmox_not_configured without a token', async () => {
|
||||
const r = await storageHealth({ apiUrl: '', token: '' });
|
||||
expect(r.error).toBe('proxmox_not_configured');
|
||||
});
|
||||
|
||||
it('fetches + normalizes via injected fetch', async () => {
|
||||
const fetchImpl = async (url) => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
if (url.includes('type=storage')) return { data: STORAGE };
|
||||
if (url.includes('type=vm')) return { data: VMS };
|
||||
if (url.includes('/nodes/z/disks/zfs')) return { data: ZFS.z };
|
||||
if (url.endsWith('/nodes')) return { data: [{ node: 'z', status: 'online' }] };
|
||||
return { data: [] };
|
||||
}
|
||||
});
|
||||
const r = await storageHealth({ apiUrl: 'https://pve:8006', token: 'tok', fetchImpl });
|
||||
expect(r.worst).toBe('crit');
|
||||
expect(r.down).toHaveLength(2);
|
||||
expect(typeof r.at).toBe('number');
|
||||
});
|
||||
});
|
||||
29
tests/views/icon_util.test.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveIcon, relativeTime, autoDefaultIcon } from '../../public/views/icon_util.js';
|
||||
|
||||
describe('autoDefaultIcon', () => {
|
||||
it('maps groups to bundled icons', () => {
|
||||
expect(autoDefaultIcon('Network')).toBe('set:devices:router');
|
||||
expect(autoDefaultIcon('Entertainment')).toBe('set:devices:tv');
|
||||
expect(autoDefaultIcon('Smart Home')).toBe('set:devices:plug');
|
||||
expect(autoDefaultIcon('Personal')).toBe('set:devices:phone');
|
||||
expect(autoDefaultIcon('whatever')).toBe('set:devices:unknown');
|
||||
});
|
||||
});
|
||||
describe('resolveIcon', () => {
|
||||
it('resolves set + brand refs', () => {
|
||||
expect(resolveIcon('set:devices:router')).toBe('/api/icon-sets/devices/router.svg');
|
||||
expect(resolveIcon('set:mine:nas')).toBe('/api/icon-sets/mine/nas.svg');
|
||||
expect(resolveIcon('brand:apple')).toBe('/api/icons/apple.png');
|
||||
});
|
||||
it('returns null for junk', () => { expect(resolveIcon('nope')).toBeNull(); });
|
||||
});
|
||||
describe('relativeTime', () => {
|
||||
const base = Date.parse('2026-06-09T12:00:00Z');
|
||||
it('formats buckets', () => {
|
||||
expect(relativeTime('2026-06-09T11:59:30Z', base)).toBe('just now');
|
||||
expect(relativeTime('2026-06-09T11:40:00Z', base)).toBe('20m ago');
|
||||
expect(relativeTime('2026-06-09T09:00:00Z', base)).toBe('3h ago');
|
||||
expect(relativeTime('2026-06-06T12:00:00Z', base)).toBe('3d ago');
|
||||
});
|
||||
});
|
||||