diff --git a/secubox-companion/www/core/api.js b/secubox-companion/www/core/api.js index 790cd1d0..17c0bbe8 100644 --- a/secubox-companion/www/core/api.js +++ b/secubox-companion/www/core/api.js @@ -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(() => ({})); }; diff --git a/secubox-companion/www/core/app.js b/secubox-companion/www/core/app.js index a99ee207..8f8b38dc 100644 --- a/secubox-companion/www/core/app.js +++ b/secubox-companion/www/core/app.js @@ -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); diff --git a/secubox-companion/www/core/metrics.js b/secubox-companion/www/core/metrics.js new file mode 100644 index 00000000..4af8f72a --- /dev/null +++ b/secubox-companion/www/core/metrics.js @@ -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}` }); +} diff --git a/secubox-companion/www/modules/auth/module.json b/secubox-companion/www/modules/auth/module.json index 767d0be1..b6a5dcdc 100644 --- a/secubox-companion/www/modules/auth/module.json +++ b/secubox-companion/www/modules/auth/module.json @@ -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" }] } diff --git a/secubox-companion/www/modules/auth/view.js b/secubox-companion/www/modules/auth/view.js index eea9146a..488424be 100644 --- a/secubox-companion/www/modules/auth/view.js +++ b/secubox-companion/www/modules/auth/view.js @@ -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) })])); + } } diff --git a/secubox-companion/www/modules/billets/view.js b/secubox-companion/www/modules/billets/view.js index 06b34515..48f51096 100644 --- a/secubox-companion/www/modules/billets/view.js +++ b/secubox-companion/www/modules/billets/view.js @@ -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'); } diff --git a/secubox-companion/www/modules/system/view.js b/secubox-companion/www/modules/system/view.js index be48d75c..a02a245d 100644 --- a/secubox-companion/www/modules/system/view.js +++ b/secubox-companion/www/modules/system/view.js @@ -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) })])); + } } diff --git a/secubox-companion/www/styles/charte.css b/secubox-companion/www/styles/charte.css index e518ae74..1239d7be 100644 --- a/secubox-companion/www/styles/charte.css +++ b/secubox-companion/www/styles/charte.css @@ -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; } diff --git a/secubox-companion/www/sw.js b/secubox-companion/www/sw.js index 795dd260..b7300f46 100644 --- a/secubox-companion/www/sw.js +++ b/secubox-companion/www/sw.js @@ -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', ];