feat(companion): real auth/system modules, emoji metrics, 401 re-login

The auth and system modules were 34-line stubs guessing routes — auth called
/api/v1/auth/users, which 404s; identities actually live under /api/v1/users.
Both are rebuilt against routes read from the handler source, not guessed.

- core/metrics.js: emoji/severity gauges + human formatters. Thresholds are
  PER KIND because a disk at 94% and a cpu at 94% carry different risk and must
  not look alike — gk2's disk (94.5%) reads 🔴 while its cpu (10%) reads 🟢.
  Plain language over field names ("Memory 75% — 5.5 GB of 7.7 GB used").
- modules/auth: /api/v1/users/{users,sessions,roles,groups}; sessions rendered
  prominently (who / IP / device / age / current), which is the point of the
  session work landing alongside it.
- modules/system: /status, /metrics, /resources, /health_score, /network,
  /security — each section degrades independently so one failing route cannot
  blank the module.
- billets: tag chip bar + per-billet emoji chips, quick-view filtering, and
  short resumes instead of full bodies; list now uses the authoring endpoint,
  so Edit/Delete address real ids and drafts are visible.
- 401 re-login: box tokens live 24h, and the Companion seals one at pairing and
  reuses it — so a day later every authed call 401'd with no way out but
  unpairing ("Failed: unauthorized" in billets and podcaster). api.js now asks
  the app to re-authenticate and REPLAYS the request once; single-flight, so a
  screenful of concurrent 401s yields one prompt, and an in-flight write (a
  billet just typed) survives instead of being lost.
- sw.js v9→v10, metrics.js added to the offline shell.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-17 10:35:37 +02:00
parent f9302df075
commit aef06b8b22
9 changed files with 484 additions and 66 deletions

View File

@ -12,6 +12,25 @@ let BASE = ''; // e.g. https://box.example.in
let TOKEN = '';
const listeners = new Set();
// Box tokens expire (24h). The Companion seals one at pairing and reuses it, so
// without this the app dead-ends on "unauthorized" a day later with no way back
// except unpairing. On a 401 we ask the app to re-authenticate, then retry the
// request once — single-flight, so a screenful of concurrent 401s produces ONE
// login prompt, not one per request.
let _onUnauthorized = null;
let _reauthInFlight = null;
async function reauthenticate() {
if (!_onUnauthorized) return false;
if (!_reauthInFlight) {
_reauthInFlight = Promise.resolve()
.then(() => _onUnauthorized())
.catch(() => false)
.finally(() => { _reauthInFlight = null; });
}
return await _reauthInFlight;
}
function emit() { for (const fn of listeners) try { fn(state()); } catch {} }
function state() { return { online: navigator.onLine, base: BASE }; }
@ -24,6 +43,10 @@ export const api = {
isReady() { return !!BASE && !!TOKEN; },
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
/** Register the app's re-login flow. It must return truthy once fresh
* credentials have been passed to api.init(), falsy if the user cancelled. */
onUnauthorized(fn) { _onUnauthorized = fn; },
headers(extra = {}) {
const h = { 'Accept': 'application/json', ...extra };
if (TOKEN) h['Authorization'] = 'Bearer ' + TOKEN;
@ -31,11 +54,15 @@ export const api = {
},
/** GET with cache fallback. `path` is absolute on the box (e.g. /api/v1/billets/feed). */
async get(path, { cache = true } = {}) {
async get(path, { cache = true, _retried = false } = {}) {
const url = BASE + path;
try {
const r = await fetch(url, { headers: this.headers(), credentials: 'include' });
if (r.status === 401) throw new ApiError('unauthorized', 401);
if (r.status === 401) {
// Expired/rotated token → re-login once, then replay this read.
if (!_retried && await reauthenticate()) return this.get(path, { cache, _retried: true });
throw new ApiError('unauthorized', 401);
}
if (!r.ok) throw new ApiError(await safeText(r), r.status);
const data = await r.json().catch(() => ({}));
if (cache) store.cachePut('GET ' + path, data).catch(() => {});
@ -56,13 +83,18 @@ export const api = {
/** Send a write, or queue it if offline / the network drops.
* Returns { queued:true, id } when deferred, otherwise the parsed response. */
async _write(method, path, body, { form = false } = {}) {
const send = async () => {
const send = async (retried = false) => {
const headers = form ? this.headers() : this.headers({ 'Content-Type': 'application/json' });
const r = await fetch(BASE + path, {
method, headers, credentials: 'include',
body: body == null ? undefined : (form ? body : JSON.stringify(body)),
});
if (r.status === 401) throw new ApiError('unauthorized', 401);
if (r.status === 401) {
// Re-login and replay ONCE — losing a billet the author just wrote to an
// expired token would be the worst possible outcome here.
if (!retried && await reauthenticate()) return send(true);
throw new ApiError('unauthorized', 401);
}
if (!r.ok) throw new ApiError(await safeText(r), r.status);
return r.json().catch(() => ({}));
};

View File

@ -121,6 +121,20 @@ function route() {
// ── boot ──────────────────────────────────────────────────────────
async function boot(forceLock = false) {
// Box tokens live 24h; the sealed one goes stale and every authed call then
// 401s. Re-login in place (the URL is prefilled) and re-render, so an expired
// session is a prompt rather than a dead end — api.js replays the request that
// hit the 401, so an in-progress write is not lost.
api.onUnauthorized(async () => {
const creds = await pairingScreen(app);
if (!creds || !creds.token) return false;
api.init(creds);
chrome();
await registry.discover();
route();
return true;
});
if (!store.isPaired()) {
const creds = await pairingScreen(app);
api.init(creds);

View File

@ -0,0 +1,100 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// SecuBox Companion :: core/metrics — emoji gauges + human formatters.
//
// Companion is glanced at on a phone, often in passing — raw psutil numbers
// ("disk_percent: 94.5") don't read as urgent fast enough. This module turns
// any percent-ish value into a severity-coded gauge (emoji + colour + bar)
// using PER-KIND thresholds, because a disk at 94% and a cpu at 94% do not
// carry the same risk and must not look the same at a glance.
import { el } from './ui.js';
// [warnAt, critAt] as a 0-100 value, per metric kind. Disk fills up silently
// and is the most dangerous to under-alarm on, so it's the strictest; cpu
// bursts constantly and is tolerated much higher before it means anything.
const THRESHOLDS = {
cpu: [70, 90],
mem: [75, 90],
disk: [80, 92], // 94.5% on this box → correctly reads as crit
temp: [70, 85], // °C, reuses the same [warn,crit] shape as a percent scale
load: [70, 90], // caller normalises load average to % of core count first
default: [70, 90],
};
const LEVELS = {
ok: { emoji: '🟢', badge: 'ok', word: 'nominal' },
warn: { emoji: '🟡', badge: 'warn', word: 'elevated' },
crit: { emoji: '🔴', badge: 'err', word: 'critical' },
};
/** Classify a 0-100 value for `kind` into 'ok' | 'warn' | 'crit'. */
export function severity(kind, value) {
if (value == null || Number.isNaN(value)) return 'ok';
const [warn, crit] = THRESHOLDS[kind] || THRESHOLDS.default;
if (value >= crit) return 'crit';
if (value >= warn) return 'warn';
return 'ok';
}
export function emojiFor(kind, value) { return LEVELS[severity(kind, value)].emoji; }
export function wordFor(kind, value) { return LEVELS[severity(kind, value)].word; }
/** bytes → "5.5 GB" — binary units, plain language over raw byte counts. */
export function fmtBytes(n) {
if (n == null || Number.isNaN(n)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let v = Number(n), i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(i === 0 || v >= 10 ? 0 : 1)} ${units[i]}`;
}
/** seconds → "7d 3h" — coarsest two units only; nobody needs the minutes. */
export function fmtUptime(sec) {
if (sec == null || Number.isNaN(sec)) return '—';
sec = Math.floor(sec);
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function fmtPercent(n, digits = 0) {
if (n == null || Number.isNaN(n)) return '—';
return `${Number(n).toFixed(digits)}%`;
}
/**
* "Memory 75% — 5.5 GB of 7.7 GB used" style plain-language sentence.
* Prefer this over showing a bare field name + number.
*/
export function usageSentence(label, percent, used, total) {
const pct = fmtPercent(percent);
if (used == null || total == null) return `${label} ${pct}`;
return `${label} ${pct}${fmtBytes(used)} of ${fmtBytes(total)} used`;
}
/**
* A gauge card: emoji + plain label + big value + proportional bar.
* `percent` (0-100) drives both the bar width and the severity colour/emoji;
* pass `value`/`unit` when the displayed text isn't the percent itself
* (e.g. a °C reading, still classified on the same 0-100 severity scale).
*/
export function gauge({ kind = 'default', label, percent, value, unit = '%', sub }) {
const lvl = severity(kind, percent);
const { emoji, badge } = LEVELS[lvl];
const shown = value != null ? `${value}${unit}` : fmtPercent(percent);
const pct = Math.max(0, Math.min(100, percent ?? 0));
return el('div.card', { style: 'text-align:center' }, [
el('div', { style: 'font-size:1.3rem;line-height:1', text: emoji }),
el('div.data', { style: 'font-size:1.35rem;font-weight:700;margin:3px 0', text: shown }),
el('div.kicker', { text: label }),
el('div.gauge-bar', {}, [el(`div.gauge-fill.${badge}`, { style: `width:${pct}%` })]),
sub ? el('div.muted', { style: 'font-size:.7rem;margin-top:4px', text: sub }) : null,
]);
}
/** Small severity badge for non-gauge contexts (list rows, headers). */
export function severityBadge(kind, value, text) {
const { badge, emoji } = LEVELS[severity(kind, value)];
return el(`span.badge.${badge}`, { text: `${emoji} ${text}` });
}

View File

@ -4,8 +4,8 @@
"module": "AUTH",
"icon": "🔑",
"description": "Identities, sessions, access.",
"api_base": "/api/v1/auth",
"status_endpoint": "/api/v1/auth/status",
"api_base": "/api/v1/users",
"status_endpoint": "/api/v1/users/status",
"capabilities": ["read"],
"routes": [{ "path": "", "label": "Identities" }]
}

View File

@ -1,34 +1,146 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// SecuBox Companion :: auth — identities & sessions (read).
//
// The stub this replaces called `${base}/users` against api_base
// `/api/v1/auth`, which 404s — auth identity data actually lives under
// `/api/v1/users` (see module.json, updated alongside this file). Routes
// below are verified live against packages/secubox-users/api/main.py:
// /users (JWT) → { users:[{username,email,role,enabled,totp}], total }
// /sessions (JWT) → { sessions:[{id,username,email,ip,user_agent,type,
// created_at,expires_at,last_active,current}], total }
// /roles → { roles:[{id,name,description,permissions,builtin,color}], total }
// /groups (JWT) → { groups:[{name,description,permissions,members,created}] }
// Each section is fetched independently so one failing route degrades to a
// small inline notice instead of blanking the whole module.
import { gauge } from '../../core/metrics.js';
// The 'master' concept isn't a stored field — the source (api/main.py,
// engine.py) consistently treats the single literal username "admin" as
// the master account (e.g. the only one synced to YaCy). Mirror that here.
const isMaster = (u) => u.username === 'admin';
// `created_at`/`expires_at` come from a JWT session file: sometimes a raw
// epoch (seconds OR the rarer ms), sometimes an ISO string, sometimes ''.
// ui.ago() assumes a bare number is already milliseconds, so seconds must
// be upscaled first or old sessions render as "1970".
function toMs(ts) {
if (ts === '' || ts == null) return null;
if (typeof ts === 'number') return ts < 1e12 ? ts * 1000 : ts;
const p = Date.parse(ts);
return Number.isNaN(p) ? null : p;
}
function until(ts) {
const ms = toMs(ts);
if (ms == null) return '—';
const s = Math.floor((ms - Date.now()) / 1000);
if (s <= 0) return 'expired';
if (s < 3600) return `in ${Math.floor(s / 60)}m`;
if (s < 86400) return `in ${Math.floor(s / 3600)}h`;
return `in ${Math.floor(s / 86400)}d`;
}
// IP may be '' (pre-fix rows) or the literal string "unknown" (the API's
// own fallback when the key is absent entirely) — both read as "no data",
// so both collapse to the same neutral placeholder rather than leaking
// either raw form to the UI.
const ipOr = (ip) => (ip && ip !== 'unknown') ? ip : '—';
export default async function mount(ctx) {
const { root, api, base, ui } = ctx;
const { el, esc, ago, clear } = ui;
const host = clear(root);
host.append(el('p.muted', { text: 'Loading identities…' }));
clear(host);
host.append(el('div.panel-head', {}, [
el('div', { style: 'font-size:1.6rem', text: '🔑' }),
el('h3', { style: 'margin:0', text: 'Auth' }),
]));
// ── sessions (prominent — who / IP / device / age / current) ────
try {
const users = await api.get(`${base}/users`).catch(() => ({ users: [] })); // TODO(api): confirm users route
const list = users.users || users.items || [];
clear(host);
const card = el('div.card', {}, [el('h3', { text: `Users (${list.length})` })]);
if (!list.length) card.append(el('div.empty', { text: 'No users listed (auth users route may be admin-gated — TODO(api)).' }));
for (const u of list) card.append(el('div.item', {}, [
const d = await api.get(`${base}/sessions`);
const items = d.sessions || [];
const card = el('div.card', {}, [el('h3', { text: `🟢 Active sessions (${d.total ?? items.length})` })]);
if (!items.length) card.append(el('div.empty', { text: 'No active sessions.' }));
for (const s of items) {
const device = s.user_agent || '—';
card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', {}, [esc(s.username || '?'), s.current ? el('span.badge.ok', { style: 'margin-left:6px', text: '📍 this device' }) : null]),
el('span', { text: `${ipOr(s.ip)} · ${esc(device)} · signed in ${ago(toMs(s.created_at) ?? undefined) || '—'}` }),
]),
el('span.badge' + (until(s.expires_at) === 'expired' ? '.err' : ''), { text: '⏳ ' + until(s.expires_at) }),
]));
}
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Sessions unavailable: ' + esc(e.message) })]));
}
// ── identities ────────────────────────────────────────────────
try {
const d = await api.get(`${base}/users`);
const list = d.users || [];
const enabled = list.filter(u => u.enabled !== false).length;
const disabled = list.length - enabled;
const card = el('div.card', {}, [el('h3', { text: `👤 Identities (${d.total ?? list.length})` })]);
if (list.length) {
// Gauge on the *disabled* share, not enabled — severity() reads "higher
// = worse", and a pile of locked-out accounts is the anomaly worth
// flagging, not the (normal) state of everyone being enabled.
card.append(gauge({ kind: 'default', label: 'Accounts disabled', percent: (disabled / list.length) * 100, value: `${enabled}/${list.length} enabled`, unit: '' }));
} else {
card.append(el('div.empty', { text: 'No users listed.' }));
}
for (const u of list) {
const twofa = u.totp && u.totp.enabled;
card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', {}, [esc(u.username), isMaster(u) ? el('span.badge.ok', { style: 'margin-left:6px', text: '★ master' }) : null]),
el('span', { text: [u.role || (u.roles || []).join(',') || 'viewer', esc(u.email || ''), twofa ? '🔐 2FA' : null].filter(Boolean).join(' · ') }),
]),
el('span.badge' + (u.enabled === false ? '.err' : '.ok'), { text: u.enabled === false ? '🔴 disabled' : '🟢 enabled' }),
]));
}
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Identities unavailable: ' + esc(e.message) })]));
}
// ── roles ─────────────────────────────────────────────────────
try {
const d = await api.get(`${base}/roles`);
const roles = d.roles || [];
const card = el('div.card', {}, [el('h3', { text: `🎭 Roles (${d.total ?? roles.length})` })]);
if (!roles.length) card.append(el('div.empty', { text: 'No roles defined.' }));
for (const r of roles) card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', { text: esc(u.username || u.name || u.id) }),
el('span', { text: [u.role, u.admin && 'admin', u.totp && '2FA'].filter(Boolean).join(' · ') }),
el('b', { text: esc(r.name || r.id) }),
el('span', { text: `${esc(r.description || '')} · ${(r.permissions || []).length} permissions` }),
]),
u.master ? el('span.badge.ok', { text: 'master' }) : null,
r.builtin ? el('span.badge', { text: 'builtin' }) : null,
]));
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Roles unavailable: ' + esc(e.message) })]));
}
const sess = await api.get(`${base}/sessions`).catch(() => null); // TODO(api): confirm sessions route
if (sess) {
const items = sess.sessions || sess.items || [];
const sc = el('div.card', {}, [el('h3', { text: `Active sessions (${items.length})` })]);
for (const s of items.slice(0, 30)) sc.append(el('div.item', {}, [
el('div.meta', {}, [el('b', { text: esc(s.user || s.username || '?') }), el('span', { text: `${esc(s.ip || '')} · ${ago(s.created_at || s.ts)}` })]),
]));
host.append(sc);
}
} catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); }
// ── groups ────────────────────────────────────────────────────
try {
const d = await api.get(`${base}/groups`);
const groups = d.groups || [];
const card = el('div.card', {}, [el('h3', { text: `👥 Groups (${groups.length})` })]);
if (!groups.length) card.append(el('div.empty', { text: 'No groups defined.' }));
for (const g of groups) card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', { text: esc(g.name) }),
el('span', { text: `${esc(g.description || '')} · ${(g.members || []).length} members` }),
]),
]));
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Groups unavailable: ' + esc(e.message) })]));
}
}

View File

@ -1,14 +1,16 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// SecuBox Companion :: billets — write/publish billets + moderate comments.
//
// Endpoints below are the clean interface the Companion consumes. Where the
// exact box route/shape is unconfirmed it is marked TODO(api); the box may need
// a JWT-authed admin API for billets (its web admin is session-based). Reads use
// the public feed and work today; writes go through these paths + auto-queue.
// All routes below are served by billets' JWT/SSO admin surface (routes/jwt_admin.py),
// which trusts the SecuBox session — reads use the public JSON Feed.
// Every route below is served by billets' JWT/SSO admin surface
// (routes/jwt_admin.py), which trusts the SecuBox session.
//
// The list deliberately uses the ADMIN route, not the public JSON Feed: the
// feed's item `id` is a permalink URL rather than the billet id (so edit/delete
// could not address a row) and the feed omits drafts, which an authoring client
// must see.
const EP = {
feed: (b) => `${b}/feed.json`, // public JSON Feed (jsonfeed.org)
list: (b) => `${b}/admin/api/billets`, // GET {billets:[{id,summary,status,tags[]}]}
tags: (b) => `${b}/admin/api/tags`, // GET {tags:[{slug,emoji,count}]}
create: (b) => `${b}/admin/api/billets`, // POST {body,ref_url,embed_url,style,status}
update: (b, id) => `${b}/admin/api/billets/${id}`, // PUT
remove: (b, id) => `${b}/admin/api/billets/${id}`, // DELETE
@ -43,24 +45,59 @@ export default async function mount(ctx) {
}
// ── list ────────────────────────────────────────────────────────
let tagFilter = ''; // '' = all; otherwise a tag slug (the quick view)
async function list() {
const host = clear(body);
host.append(el('p.muted', { text: 'Loading…' }));
try {
const d = await api.get(EP.feed(base));
const items = d.billets || d.items || d.feed || (Array.isArray(d) ? d : []);
const d = await api.get(EP.list(base));
const items = d.billets || [];
clear(host);
if (d.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' }));
if (!items.length) return host.append(el('div.empty', { text: 'No billets yet.' }));
for (const b of items) {
const title = (b.title || b.body || '').replace(/\s+/g, ' ').slice(0, 70) || '(untitled)';
// Quick-view chip bar. Tags come from the #hashtags authors type in the
// body; failing to load them must not hide the billets themselves.
const td = await api.get(EP.tags(base)).catch(() => null);
const tags = (td && td.tags) || [];
if (tags.length) {
const bar = el('div.row', { style: 'gap:6px;margin-bottom:12px;flex-wrap:wrap' });
const chip = (label, slug) => el(`button.btn.sm${tagFilter === slug ? '.primary' : ''}`, {
text: label,
onclick: () => { tagFilter = tagFilter === slug ? '' : slug; list(); },
});
bar.append(chip('🌀 All', ''));
for (const t of tags) bar.append(chip(`${t.emoji} #${t.slug} ${t.count}`, t.slug));
host.append(bar);
}
const shown = tagFilter
? items.filter(b => (b.tags || []).some(t => t.slug === tagFilter))
: items;
if (!shown.length) {
return host.append(el('div.empty', {
text: tagFilter ? `No billets tagged #${tagFilter}.` : 'No billets yet.',
}));
}
for (const b of shown) {
// Show a few words, not the whole billet — `summary` is the box-side
// excerpt; fall back to a clipped body if it is ever absent.
const excerpt = b.summary || (b.body || '').replace(/\s+/g, ' ').slice(0, 140) || '(empty)';
const chips = el('span', { style: 'margin-left:6px' });
for (const t of (b.tags || []))
chips.append(el('span.badge', { text: `${t.emoji} ${t.slug}`, style: 'margin-right:4px' }));
const draft = b.status === 'draft';
host.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', { text: title }),
el('span', { text: `${b.status || 'published'} · ${ago(b.date_published || b.published_at || b.created_at)} · ${b.style || 'default'}` }),
el('b', { text: excerpt }),
el('span', {}, [
el('span', { text: `${draft ? '📝 draft' : '✅ published'} · ${ago(b.published_at || b.created_at)}${b.style === 'communique' ? ' · 📢 communiqué' : ''}` }),
chips,
]),
]),
el('div.actions', {}, [
el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + (b.id || b.slug); renderTabs(); editor(b.id || b.slug, b); } }),
el('button.btn.sm', { text: 'Edit', onclick: () => { active = 'edit:' + b.id; renderTabs(); editor(b.id, b); } }),
el('button.btn.sm.danger', { text: 'Del', onclick: () => remove(b) }),
]),
]));
@ -71,7 +108,7 @@ export default async function mount(ctx) {
async function remove(b) {
if (!confirmAction('Delete this billet?')) return;
try {
const r = await api.del(EP.remove(base, b.id || b.slug));
const r = await api.del(EP.remove(base, b.id));
toast(r.queued ? 'Delete queued (offline)' : 'Deleted');
list();
} catch (e) { toast('Delete failed: ' + e.message, 'err'); }

View File

@ -1,34 +1,148 @@
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
// SecuBox Companion :: system — health, resources, services (read).
//
// Routes below are verified live against packages/secubox-system/api/main.py:
// /status (JWT) → { model, board, hostname, uptime_sec, cpu_count,
// mem_total_mb, mem_free_mb, services_sample, health, cached_at }
// /metrics → { cpu_percent, mem_percent, disk_percent, load_avg_1,
// cpu_temp, uptime_seconds, hostname, memory_used, memory_total }
// /resources → { cpu_percent, memory_percent, memory_used, memory_total,
// disk_percent, disk_used, disk_total, load_avg:[1,5,15] }
// /health_score (JWT) → { score, max, issues:[name…], services:[{name,active,status,enabled}] }
// /network → { interfaces:[{name,addresses,up,speed}] }
// /security → { firewall, ssh_status, apparmor, crowdsec } (plain status words)
// Each section is fetched independently so one failing route degrades to a
// small inline notice instead of blanking the whole module.
import { gauge, fmtUptime, fmtBytes } from '../../core/metrics.js';
export default async function mount(ctx) {
const { root, api, base, ui } = ctx;
const { el, esc, clear } = ui;
const host = clear(root);
host.append(el('p.muted', { text: 'Loading system health…' }));
try {
const s = await api.get(`${base}/status`);
clear(host);
const g = el('div.grid');
const gauge = (label, val, unit = '') => g.append(el('div.card', { style: 'text-align:center' }, [
el('div.data', { style: 'font-size:1.5rem;font-weight:700', text: (val ?? '—') + unit }),
el('div.kicker', { text: label }),
]));
gauge('CPU', s.cpu ?? s.cpu_percent, '%');
gauge('Memory', s.mem ?? s.memory_percent ?? s.mem_percent, '%');
gauge('Disk', s.disk ?? s.disk_percent, '%');
gauge('Uptime', s.uptime_days ?? s.uptime, s.uptime_days ? 'd' : '');
host.append(g);
const svcs = await api.get(`${base}/services`).catch(() => null); // TODO(api): confirm services route
if (svcs) {
const items = svcs.services || svcs.items || [];
const card = el('div.card', {}, [el('h3', { text: `Services (${items.length})` })]);
for (const sv of items.slice(0, 60)) card.append(el('div.item', {}, [
el('div.meta', {}, [el('b', { text: esc(sv.name || sv.unit) })]),
el('span.badge' + (sv.active || sv.status === 'active' || sv.running ? '.ok' : '.err'), { text: sv.status || (sv.active ? 'active' : 'down') }),
]));
host.append(card);
// /metrics is the one call the whole panel leans on (cpu/mem/disk/temp/
// uptime/hostname in a single response) — if it fails there is nothing
// meaningful to render, so that failure alone blanks the module.
let m;
try {
m = await api.get(`${base}/metrics`);
} catch (e) {
clear(host).append(el('div.empty', { text: 'System metrics unavailable: ' + esc(e.message) }));
return;
}
clear(host);
if (m.__cached) host.append(el('div.badge.warn', { text: 'cached (offline)' }));
// ── header: who / how long up ──────────────────────────────────
host.append(el('div.panel-head', {}, [
el('div', { style: 'font-size:1.6rem', text: '🖥️' }),
el('div', {}, [
el('h3', { style: 'margin:0', text: esc(m.hostname || 'SecuBox') }),
el('div.muted', { style: 'font-size:.78rem', text: `up ${fmtUptime(m.uptime_seconds)}` }),
]),
]));
// ── gauges: cpu / memory / disk / temp / load ──────────────────
const g = el('div.grid');
host.append(g);
// disk byte totals aren't in /metrics — fetch /resources for the "N GB of
// N GB" sub-line; the disk % gauge itself still renders if this fails.
let res = null;
try { res = await api.get(`${base}/resources`); } catch { /* degrade: percent-only gauges */ }
g.append(gauge({ kind: 'cpu', label: 'CPU', percent: m.cpu_percent }));
g.append(gauge({
kind: 'mem', label: 'Memory', percent: m.mem_percent,
sub: (m.memory_used != null && m.memory_total != null) ? `${fmtBytes(m.memory_used)} of ${fmtBytes(m.memory_total)} used` : undefined,
}));
g.append(gauge({
kind: 'disk', label: 'Disk', percent: m.disk_percent,
sub: (res && res.disk_used != null && res.disk_total != null) ? `${fmtBytes(res.disk_used)} of ${fmtBytes(res.disk_total)} used` : undefined,
}));
g.append(gauge({ kind: 'temp', label: 'Temperature', percent: m.cpu_temp, value: m.cpu_temp?.toFixed?.(1) ?? m.cpu_temp, unit: '°C' }));
// Load average isn't a percent on its own — normalise against core count
// from /status (JWT) when reachable; otherwise show the raw figure without
// a false severity read (an un-normalised "3.4" means nothing alone).
try {
const st = await api.get(`${base}/status`);
const cores = st.cpu_count;
const loadPct = cores ? Math.min(100, (m.load_avg_1 / cores) * 100) : null;
g.append(gauge({
kind: 'load', label: `Load (1m, ${cores || '?'} cores)`,
percent: loadPct, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '',
}));
} catch {
g.append(gauge({ kind: 'load', label: 'Load (1m)', percent: null, value: m.load_avg_1?.toFixed?.(2) ?? m.load_avg_1, unit: '' }));
}
// ── health score + services ─────────────────────────────────────
try {
const hs = await api.get(`${base}/health_score`);
const services = hs.services || [];
const scoreEmoji = hs.score >= 90 ? '🟢' : hs.score >= 70 ? '🟡' : '🔴';
const card = el('div.card', {}, [
el('h3', {}, [`${scoreEmoji} Health `, el('span.data', { text: `${hs.score ?? '—'}/${hs.max ?? 100}` })]),
]);
if ((hs.issues || []).length) {
card.append(el('div.muted', { style: 'margin-bottom:8px', text: '⚠️ ' + hs.issues.join(', ') + ' down' }));
}
} catch (e) { clear(host).append(el('div.empty', { text: 'Failed: ' + esc(e.message) })); }
for (const sv of services) {
card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', { text: esc(sv.name) }),
el('span', { text: sv.enabled ? 'enabled at boot' : 'not enabled at boot' }),
]),
el('span.badge' + (sv.active ? '.ok' : '.err'), { text: sv.active ? '🟢 ' + (sv.status || 'active') : '🔴 ' + (sv.status || 'down') }),
]));
}
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Services/health unavailable: ' + esc(e.message) })]));
}
// ── network ──────────────────────────────────────────────────────
try {
const net = await api.get(`${base}/network`);
const ifaces = net.interfaces || [];
const card = el('div.card', {}, [el('h3', { text: `📡 Network (${ifaces.length})` })]);
if (!ifaces.length) card.append(el('div.empty', { text: 'No interfaces reported.' }));
for (const i of ifaces) {
const addr = (i.addresses || []).join(', ') || 'no address';
const speed = i.up && i.speed ? ` · ${i.speed} Mbps` : '';
card.append(el('div.item', {}, [
el('div.meta', {}, [
el('b', { text: esc(i.name) }),
el('span', { text: addr + speed }),
]),
el('span.badge' + (i.up ? '.ok' : ''), { text: i.up ? '🟢 up' : '⚪ down' }),
]));
}
host.append(card);
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Network unavailable: ' + esc(e.message) })]));
}
// ── security ─────────────────────────────────────────────────────
try {
const sec = await api.get(`${base}/security`);
const good = (v) => ['Active', 'Running', 'Enabled'].includes(v);
const bad = (v) => ['Stopped', 'Disabled'].includes(v);
const row = (label, val) => el('div.item', {}, [
el('div.meta', {}, [el('b', { text: label })]),
el(`span.badge${good(val) ? '.ok' : bad(val) ? '.err' : '.warn'}`, { text: `${good(val) ? '🟢' : bad(val) ? '🔴' : '🟡'} ${val ?? '—'}` }),
]);
host.append(el('div.card', {}, [
el('h3', { text: '🛡️ Security' }),
row('Firewall', sec.firewall),
row('SSH', sec.ssh_status),
row('AppArmor', sec.apparmor),
row('CrowdSec', sec.crowdsec),
]));
} catch (e) {
host.append(el('div.card', {}, [el('div.muted', { text: 'Security status unavailable: ' + esc(e.message) })]));
}
}

View File

@ -151,6 +151,15 @@ body.offline .offline-bar { display: block; }
.queued-bar { display: none; background: var(--mind); color: #fff; font-size: .74rem; text-align: center; padding: 4px; }
body.has-queue .queued-bar { display: block; }
/* emoji gauges (core/metrics.js)
* Bar colour mirrors the .badge severity classes so a gauge and its
* matching badge always agree at a glance. */
.gauge-bar { height: 5px; border-radius: 4px; background: var(--surf-2); margin-top: 8px; overflow: hidden; }
.gauge-fill { height: 100%; border-radius: 4px; background: var(--muted); transition: width .3s; }
.gauge-fill.ok { background: var(--root); }
.gauge-fill.warn { background: var(--wall); }
.gauge-fill.err { background: var(--boot); }
.empty { color: var(--muted); text-align: center; padding: 40px 16px; font-size: .85rem; }
.muted { color: var(--muted); }
.mono-sm { font-family: var(--font-mono); font-size: .72rem; }

View File

@ -7,7 +7,7 @@
// Same-origin app assets → stale-while-revalidate. Box API (cross-origin) →
// passthrough (the page handles offline reads/queue).
const VERSION = 'sbx-companion-v8';
const VERSION = 'sbx-companion-v10';
const SHELL = [
'./', './index.html', './manifest.webmanifest',
'./styles/charte.css', './styles/fonts.css',
@ -15,7 +15,7 @@ const SHELL = [
'./styles/fonts/space-grotesk-700.woff2', './styles/fonts/jetbrains-mono-400.woff2',
'./styles/fonts/jetbrains-mono-700.woff2',
'./core/app.js', './core/api.js', './core/store.js',
'./core/registry.js', './core/auth.js', './core/ui.js',
'./core/registry.js', './core/auth.js', './core/ui.js', './core/metrics.js',
'./modules/index.json',
];