Merge claude/2

This commit is contained in:
librelad 2026-06-12 22:33:23 +01:00
commit e317962616
2 changed files with 235 additions and 37 deletions

View File

@ -69,6 +69,74 @@
border-radius: 8px; border-radius: 8px;
} }
/* ---- Overview: health hero + action board -------------------------------- */
/* The hero is the tab's "status circle": one glance answers "anything to do?".
Status colours match the admin dashboard's .admin-status-dot palette
(ok/warn/none) so the two boards speak the same language. */
.ov-health {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
padding: 18px 20px;
margin-bottom: 14px;
border-radius: 12px;
border: 1px solid rgba(var(--text-rgb), .12);
background: rgba(var(--text-rgb), .04);
}
.ov-health.ok { background: rgba(54, 211, 153, .07); border-color: rgba(54, 211, 153, .25); }
.ov-health.warn { background: rgba(251, 189, 35, .07); border-color: rgba(251, 189, 35, .3); }
.ov-health-dot { width: 18px; height: 18px; border-radius: 50%; flex: 0 0 auto; }
.ov-health-dot.ok { background: #36d399; box-shadow: 0 0 12px rgba(54, 211, 153, .55); }
.ov-health-dot.warn { background: #fbbd23; box-shadow: 0 0 12px rgba(251, 189, 35, .55); }
.ov-health-dot.none { background: rgba(var(--text-rgb), .25); }
.ov-health-main { flex: 1; min-width: 220px; }
.ov-health-title { font-weight: 600; font-size: 1.02rem; color: var(--text-primary, #fff); }
.ov-health-sub { margin-top: 3px; font-size: .82rem; color: rgba(var(--text-rgb), .55); }
.ov-health .ov-chips { flex-shrink: 0; }
/* Board rows: actionable ones are tinted with their area's identity hue and
carry buttons; ok/none rows stay neutral and quiet so they don't compete. */
.ov-board { display: flex; flex-direction: column; gap: 10px; }
.ov-board-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 13px 16px;
border-radius: 11px;
background: rgba(var(--page-rgb, var(--accent-rgb)), .06);
border: 1px solid rgba(var(--page-rgb, var(--accent-rgb)), .16);
}
.ov-board-row.ok,
.ov-board-row.none {
background: rgba(var(--text-rgb), .03);
border-color: rgba(var(--text-rgb), .08);
}
.ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
.ov-dot.ok { background: #36d399; }
.ov-dot.warn { background: #fbbd23; }
.ov-dot.none { background: rgba(var(--text-rgb), .25); }
.ov-board-icon { flex: 0 0 auto; font-size: 1.05rem; }
.ov-board-main { flex: 1; min-width: 220px; }
.ov-board-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-weight: 600;
font-size: .92rem;
color: var(--text-primary, #fff);
}
.ov-board-row.ok .ov-board-title,
.ov-board-row.none .ov-board-title { font-weight: 500; color: rgba(var(--text-rgb), .7); }
.ov-board-sub { margin-top: 3px; font-size: .8rem; color: rgba(var(--text-rgb), .5); }
.ov-board-actions { display: flex; gap: 8px; flex-shrink: 0; align-items: center; }
/* "Needs action" view with nothing to do keep it calm, the hero already
carries the good news. */
.ov-allclear { padding: 28px 20px; text-align: center; font-size: .9rem; color: rgba(var(--text-rgb), .55); }
/* ---- Updates: filter chips + toolbar ------------------------------------ */ /* ---- Updates: filter chips + toolbar ------------------------------------ */
.ov-toolbar { .ov-toolbar {
display: flex; display: flex;

View File

@ -23,7 +23,7 @@
// full BackupPage, which supplies its own header — so it has no entry to render // full BackupPage, which supplies its own header — so it has no entry to render
// but is listed for completeness.) // but is listed for completeness.)
const OV_TAB_META = { const OV_TAB_META = {
overview: { icon: '🛰️', title: 'Overview', desc: 'Updates, improvements, and backups across all your apps.' }, overview: { icon: '🛰️', title: 'Overview', desc: 'What needs your attention across all your apps — updates, security, improvements, and backups.' },
updates: { icon: '⬆️', title: 'Updates', desc: 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.' }, updates: { icon: '⬆️', title: 'Updates', desc: 'Available versions per app — expand a row for CVEs, recovery, and history. Every update is snapshotted first.' },
improvements: { icon: '✨', title: 'Improvements', desc: 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.' }, improvements: { icon: '✨', title: 'Improvements', desc: 'Signed, individually-reversible hotfixes from the LibrePortal team — applied with a snapshot first.' },
backups: { icon: '💾', title: 'Backups', desc: 'Backup health across your apps. Open an app for snapshots and restore.' }, backups: { icon: '💾', title: 'Backups', desc: 'Backup health across your apps. Open an app for snapshots and restore.' },
@ -37,6 +37,7 @@ class OverviewManager {
this.backup = null; // /data/backup/generated/dashboard.json this.backup = null; // /data/backup/generated/dashboard.json
this.tabs = null; // TabController (show/hide only) this.tabs = null; // TabController (show/hide only)
this.filter = 'all'; // Updates-tab chip: all | updates | security this.filter = 'all'; // Updates-tab chip: all | updates | security
this.board = 'all'; // Overview-tab chip: all | action
this.current = null; this.current = null;
} }
@ -305,61 +306,190 @@ class OverviewManager {
const oa = e.target.closest('[data-overview-action]'); const oa = e.target.closest('[data-overview-action]');
if (oa) { if (oa) {
switch (oa.dataset.overviewAction) { switch (oa.dataset.overviewAction) {
case 'goto': this.switchTab(oa.dataset.tab); break; case 'goto':
// Board rows can land on Updates pre-filtered (e.g. Security → the
// affected apps only), so honor an optional data-filter on the way.
if (oa.dataset.filter) this.filter = oa.dataset.filter;
this.switchTab(oa.dataset.tab);
break;
case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break; case 'filter': this.filter = oa.dataset.filter || 'all'; this.renderTab('updates'); break;
case 'board': this.board = oa.dataset.board || 'all'; this.renderTab('overview'); break;
case 'toggle': this.toggleAppDetails(oa.dataset.app); break; case 'toggle': this.toggleAppDetails(oa.dataset.app); break;
} }
} }
} }
// ---- Overview tab -------------------------------------------------------- // ---- Overview tab (action board) ------------------------------------------
// An action board, not a stat wall: a health hero on top (one big circle —
// green all-clear / amber something-to-do / neutral no-data-yet, the admin
// dashboard's dot language at fleet scale), then one row per area. Rows that
// want a decision carry their action buttons; healthy areas collapse to a
// quiet one-liner, and the "Needs action" chip hides them entirely. The raw
// numbers all live inside the rows and the hero's stat line.
renderOverview() { renderOverview() {
const u = this.updater const rows = this.areaStatuses();
? this.updater.counts() const warn = rows.filter((r) => r.kind === 'warn');
: { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0, lastChecked: null }; const scanned = !!(this.updater && this.updater.updates);
const u = this.updater ? this.updater.counts() : { apps: 0, lastChecked: null };
const b = this.backupSummary(); const b = this.backupSummary();
const sev = u.cveTotals || {}; const kind = warn.length ? 'warn' : (scanned ? 'ok' : 'none');
const checked = (this.updater && u.lastChecked) ? this.updater.fmtRel(u.lastChecked) : 'never'; const headline = warn.length
const card = (hue, big, label, sub, btn) => ` ? `${warn.length} thing${warn.length === 1 ? '' : 's'} need${warn.length === 1 ? 's' : ''} your attention`
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);"> : (scanned ? 'All clear — nothing needs your attention' : 'Waiting for the first scan');
<div class="updater-stat-big">${big}</div> let sub;
<div class="updater-stat-label">${label}</div> if (scanned) {
${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''} const checked = u.lastChecked ? this.updater.fmtRel(u.lastChecked) : 'never';
${btn || ''} const bits = [`${u.apps} app${u.apps === 1 ? '' : 's'} tracked`];
</div>`; if (this.backup) bits.push(`${b.protectedLabel} backed up`);
bits.push(`last scan ${checked}`);
sub = bits.join(' · ');
} else {
sub = 'The first automatic scan runs within a couple of minutes — or hit Check now.';
}
const chip = (id, label, n) =>
`<button class="ov-chip${this.board === id ? ' active' : ''}" data-overview-action="board" data-board="${id}">${label}${n ? ` <span class="ov-chip-n">${n}</span>` : ''}</button>`;
const shown = this.board === 'action' ? warn : rows;
const list = shown.length
? `<div class="ov-board">${shown.map((r) => this.boardRow(r)).join('')}</div>`
: `<div class="ov-allclear">Nothing needs action right now.</div>`;
return ` return `
<div class="updater-stat-grid"> <div class="ov-health ${kind}">
${card('updates', u.updatesAvailable, 'Updates available', u.updatesAvailable ? 'across your apps' : "you're current", <span class="ov-health-dot ${kind}" aria-hidden="true"></span>
`<button class="updater-btn updater-btn-primary" data-overview-action="goto" data-tab="updates">Review</button>`)} <div class="ov-health-main">
${card('verify', u.totalCves, 'Known CVEs', <div class="ov-health-title">${headline}</div>
(sev.critical || sev.high) ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues', <div class="ov-health-sub">${sub}</div>
`<button class="updater-btn" data-overview-action="goto" data-tab="updates">View</button>`)}
${card('updater', u.improvements, 'Improvements', u.improvements ? 'signed hotfixes to apply' : 'nothing pending',
`<button class="updater-btn" data-overview-action="goto" data-tab="improvements">View</button>`)}
${card('backups', b.protectedLabel, 'Backups protected', b.sub,
`<button class="updater-btn" data-overview-action="goto" data-tab="backups">Backups</button>`)}
${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)}
</div> </div>
${(this.updater && this.updater.updates) ? '' : `<div class="updater-hint">No scan data yet — the first automatic scan runs within a couple of minutes, or hit <strong>Check now</strong>.</div>`}`; <div class="ov-chips" role="group" aria-label="Board filter">${chip('all', 'Everything')}${chip('action', 'Needs action', warn.length)}</div>
</div>
${list}`;
}
// One status per area. kind 'warn' = actionable (gets buttons), 'ok' = quiet
// one-liner, 'none' = no data yet. Amber is the ceiling — per the
// not-alarmist convention nothing on this board is *broken*, it just wants a
// decision; severity detail (e.g. critical CVEs) lives in the row's badge.
areaStatuses() {
const esc = (s) => this.escape(s);
const up = this.updater;
const u = up ? up.counts() : { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0 };
const scanned = !!(up && up.updates);
const goto = (tab, label, filter, primary) =>
`<button class="updater-btn${primary ? ' updater-btn-primary' : ''}" data-overview-action="goto" data-tab="${tab}"${filter ? ` data-filter="${filter}"` : ''}>${label}</button>`;
const rows = [];
// Updates
const pending = up ? up.apps.filter((a) => a.update_available) : [];
if (!scanned) {
rows.push({ hue: 'updates', icon: '⬆️', kind: 'none', text: 'Updates — not scanned yet' });
} else if (pending.length) {
const names = pending.map((a) => a.displayName || a.name);
const list = names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : '');
rows.push({
hue: 'updates', icon: '⬆️', kind: 'warn',
text: `${pending.length} update${pending.length === 1 ? '' : 's'} available`,
sub: `${esc(list)} — a recovery snapshot is taken before each update`,
actions: goto('updates', 'Review', 'updates')
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
});
} else {
rows.push({
hue: 'updates', icon: '⬆️', kind: 'ok',
text: u.apps ? `Updates — all ${u.apps} app${u.apps === 1 ? '' : 's'} current` : 'Updates — no installed apps to track yet',
});
}
// Security (needs scan data — without it the updates row already says so)
if (scanned) {
const sev = u.cveTotals || {};
if (u.totalCves) {
const hit = up.apps.filter((a) => (a.cves || []).length).length;
const order = ['critical', 'high', 'medium', 'low'];
const worst = order.find((s) => sev[s]);
rows.push({
hue: 'verify', icon: '🛡️', kind: 'warn',
text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`,
badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '',
sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '),
actions: goto('updates', 'Review', 'security'),
});
} else {
rows.push({ hue: 'verify', icon: '🛡️', kind: 'ok', text: 'Security — no known CVEs' });
}
}
// Improvements (signed hotfixes — independent of the version scan)
if (u.improvements) {
rows.push({
hue: 'updater', icon: '✨', kind: 'warn',
text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`,
sub: 'individually reversible — a snapshot is taken first',
actions: goto('improvements', 'View'),
});
} else {
rows.push({ hue: 'updater', icon: '✨', kind: 'ok', text: 'Improvements — nothing pending' });
}
// Backups
const b = this.backupSummary();
if (!this.backup) {
rows.push({ hue: 'backups', icon: '💾', kind: 'none', text: 'Backups — no backup data yet' });
} else if (b.apps && !b.locations) {
rows.push({
hue: 'backups', icon: '💾', kind: 'warn',
text: 'No backup location configured',
sub: 'apps cant be protected until theres somewhere to back them up to',
actions: goto('backups', 'Open Backups'),
});
} else if (b.never + b.old) {
const n = b.never + b.old;
rows.push({
hue: 'backups', icon: '💾', kind: 'warn',
text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`,
sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '),
actions: goto('backups', 'Open Backups'),
});
} else {
rows.push({
hue: 'backups', icon: '💾', kind: 'ok',
text: b.apps ? `Backups — all ${b.apps} app${b.apps === 1 ? '' : 's'} protected · last ${b.latest}` : 'Backups — no apps to protect yet',
});
}
return rows;
}
boardRow(r) {
return `<div class="ov-board-row ${r.kind}" style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);">
<span class="ov-dot ${r.kind}" aria-hidden="true"></span>
<span class="ov-board-icon" aria-hidden="true">${r.icon}</span>
<div class="ov-board-main">
<div class="ov-board-title">${r.text}${r.badge || ''}</div>
${r.sub ? `<div class="ov-board-sub">${r.sub}</div>` : ''}
</div>
${r.actions ? `<div class="ov-board-actions">${r.actions}</div>` : ''}
</div>`;
} }
backupSummary() { backupSummary() {
const d = this.backup; const d = this.backup;
if (!d) return { protectedLabel: '—', sub: 'no backup data', stale: 0 }; if (!d) return { apps: 0, protectedLabel: '—', never: 0, old: 0, locations: 0, latest: 'never' };
const apps = Array.isArray(d.apps) ? d.apps : []; const apps = Array.isArray(d.apps) ? d.apps : [];
const protectedCount = apps.filter((a) => a.latest_snapshot).length;
const now = Date.now(); const now = Date.now();
const stale = apps.filter((a) => { let never = 0, old = 0, newest = 0;
if (!a.latest_time) return true; for (const a of apps) {
if (!a.latest_snapshot) { never++; continue; }
const t = Date.parse(a.latest_time); const t = Date.parse(a.latest_time);
return isNaN(t) ? true : (now - t) > 7 * 86400 * 1000; if (isNaN(t) || (now - t) > 7 * 86400 * 1000) old++;
}).length; if (!isNaN(t) && t > newest) newest = t;
const latest = (d.system && d.system.latest_time && this.updater) ? this.updater.fmtRel(d.system.latest_time) : 'never'; }
const sysT = d.system ? Date.parse(d.system.latest_time) : NaN;
if (!isNaN(sysT) && sysT > newest) newest = sysT;
return { return {
protectedLabel: `${protectedCount}/${apps.length || 0}`, apps: apps.length,
sub: stale ? `${stale} need attention · last ${latest}` : `all current · last ${latest}`, protectedLabel: `${apps.length - never}/${apps.length}`,
stale, never, old,
locations: Array.isArray(d.locations) ? d.locations.length : 0,
latest: (newest && this.updater) ? this.updater.fmtRel(new Date(newest).toISOString()) : 'never',
}; };
} }