Merge claude/2
This commit is contained in:
commit
e317962616
@ -69,6 +69,74 @@
|
||||
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 ------------------------------------ */
|
||||
.ov-toolbar {
|
||||
display: flex;
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
// full BackupPage, which supplies its own header — so it has no entry to render
|
||||
// but is listed for completeness.)
|
||||
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.' },
|
||||
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.' },
|
||||
@ -37,6 +37,7 @@ class OverviewManager {
|
||||
this.backup = null; // /data/backup/generated/dashboard.json
|
||||
this.tabs = null; // TabController (show/hide only)
|
||||
this.filter = 'all'; // Updates-tab chip: all | updates | security
|
||||
this.board = 'all'; // Overview-tab chip: all | action
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
@ -305,61 +306,190 @@ class OverviewManager {
|
||||
const oa = e.target.closest('[data-overview-action]');
|
||||
if (oa) {
|
||||
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 'board': this.board = oa.dataset.board || 'all'; this.renderTab('overview'); 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() {
|
||||
const u = this.updater
|
||||
? this.updater.counts()
|
||||
: { updatesAvailable: 0, totalCves: 0, cveTotals: {}, improvements: 0, apps: 0, lastChecked: null };
|
||||
const rows = this.areaStatuses();
|
||||
const warn = rows.filter((r) => r.kind === 'warn');
|
||||
const scanned = !!(this.updater && this.updater.updates);
|
||||
const u = this.updater ? this.updater.counts() : { apps: 0, lastChecked: null };
|
||||
const b = this.backupSummary();
|
||||
const sev = u.cveTotals || {};
|
||||
const checked = (this.updater && u.lastChecked) ? this.updater.fmtRel(u.lastChecked) : 'never';
|
||||
const card = (hue, big, label, sub, btn) => `
|
||||
<div class="updater-stat" style="--page: var(--page-${hue}); --page-rgb: var(--page-${hue}-rgb);">
|
||||
<div class="updater-stat-big">${big}</div>
|
||||
<div class="updater-stat-label">${label}</div>
|
||||
${sub ? `<div class="updater-stat-sub">${sub}</div>` : ''}
|
||||
${btn || ''}
|
||||
</div>`;
|
||||
const kind = warn.length ? 'warn' : (scanned ? 'ok' : 'none');
|
||||
const headline = warn.length
|
||||
? `${warn.length} thing${warn.length === 1 ? '' : 's'} need${warn.length === 1 ? 's' : ''} your attention`
|
||||
: (scanned ? 'All clear — nothing needs your attention' : 'Waiting for the first scan');
|
||||
let sub;
|
||||
if (scanned) {
|
||||
const checked = u.lastChecked ? this.updater.fmtRel(u.lastChecked) : 'never';
|
||||
const bits = [`${u.apps} app${u.apps === 1 ? '' : 's'} tracked`];
|
||||
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 `
|
||||
<div class="updater-stat-grid">
|
||||
${card('updates', u.updatesAvailable, 'Updates available', u.updatesAvailable ? 'across your apps' : "you're current",
|
||||
`<button class="updater-btn updater-btn-primary" data-overview-action="goto" data-tab="updates">Review</button>`)}
|
||||
${card('verify', u.totalCves, 'Known CVEs',
|
||||
(sev.critical || sev.high) ? `${sev.critical || 0} critical · ${sev.high || 0} high` : 'no high-severity issues',
|
||||
`<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 class="ov-health ${kind}">
|
||||
<span class="ov-health-dot ${kind}" aria-hidden="true"></span>
|
||||
<div class="ov-health-main">
|
||||
<div class="ov-health-title">${headline}</div>
|
||||
<div class="ov-health-sub">${sub}</div>
|
||||
</div>
|
||||
<div class="ov-chips" role="group" aria-label="Board filter">${chip('all', 'Everything')}${chip('action', 'Needs action', warn.length)}</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>`}`;
|
||||
${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 can’t be protected until there’s 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() {
|
||||
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 protectedCount = apps.filter((a) => a.latest_snapshot).length;
|
||||
const now = Date.now();
|
||||
const stale = apps.filter((a) => {
|
||||
if (!a.latest_time) return true;
|
||||
let never = 0, old = 0, newest = 0;
|
||||
for (const a of apps) {
|
||||
if (!a.latest_snapshot) { never++; continue; }
|
||||
const t = Date.parse(a.latest_time);
|
||||
return isNaN(t) ? true : (now - t) > 7 * 86400 * 1000;
|
||||
}).length;
|
||||
const latest = (d.system && d.system.latest_time && this.updater) ? this.updater.fmtRel(d.system.latest_time) : 'never';
|
||||
if (isNaN(t) || (now - t) > 7 * 86400 * 1000) old++;
|
||||
if (!isNaN(t) && t > newest) newest = t;
|
||||
}
|
||||
const sysT = d.system ? Date.parse(d.system.latest_time) : NaN;
|
||||
if (!isNaN(sysT) && sysT > newest) newest = sysT;
|
||||
return {
|
||||
protectedLabel: `${protectedCount}/${apps.length || 0}`,
|
||||
sub: stale ? `${stale} need attention · last ${latest}` : `all current · last ${latest}`,
|
||||
stale,
|
||||
apps: apps.length,
|
||||
protectedLabel: `${apps.length - never}/${apps.length}`,
|
||||
never, old,
|
||||
locations: Array.isArray(d.locations) ? d.locations.length : 0,
|
||||
latest: (newest && this.updater) ? this.updater.fmtRel(new Date(newest).toISOString()) : 'never',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user