diff --git a/css/dashboard.css b/css/dashboard.css index fcc73f3..9a30941 100755 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -723,3 +723,34 @@ color: var(--text-muted); line-height: 1.45; } + +.resource-row + .resource-row { + margin-top: 0.5rem; +} + +.resource-line { + display: flex; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.78rem; +} + +.resource-line span:last-child { + color: var(--text); + text-align: right; +} + +.resource-meter { + height: 0.35rem; + margin-top: 0.25rem; + overflow: hidden; + border-radius: 999px; + background: var(--surface); +} + +.resource-meter span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--green); +} diff --git a/data/manifest.json b/data/manifest.json index 0481efb..b5558b7 100755 --- a/data/manifest.json +++ b/data/manifest.json @@ -24,14 +24,20 @@ "title": "Authoring detail", "integration": "authoring" }, - { - "id": "watcher", - "type": "badge", - "title": "Lima watcher", - "integration": "watcher" - }, - { - "id": "timesheet-summary", + { + "id": "watcher", + "type": "badge", + "title": "Lima watcher", + "integration": "watcher" + }, + { + "id": "system-resources", + "type": "systemResources", + "title": "System resources", + "integration": "resources" + }, + { + "id": "timesheet-summary", "type": "timesheet", "title": "Hours this week", "integration": "timesheet" diff --git a/js/dashboard.js b/js/dashboard.js index 8720d53..c90be75 100755 --- a/js/dashboard.js +++ b/js/dashboard.js @@ -5,12 +5,13 @@ const logStreams = new Map(); let statusPollTimer = null; /** Fallback when manifest API fails and static file is an older v1 without widgets. */ -const DEFAULT_WIDGETS = [ - { id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' }, - { id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' }, - { id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' }, - { id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' }, - { id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' }, +const DEFAULT_WIDGETS = [ + { id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' }, + { id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' }, + { id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' }, + { id: 'system-resources', type: 'systemResources', title: 'System resources', integration: 'resources' }, + { id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' }, + { id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' }, { id: 'calibre-sync', type: 'log', title: 'Calibre export', integration: 'scripts', logKey: 'calibreSync' }, { id: 'org-web-build', type: 'buildStatus', title: 'Website build', integration: 'builds', lastRunKey: 'org_web' }, ]; @@ -209,15 +210,28 @@ function updateWidgetCard(widget, status) { body.textContent = w.SubState ? `SubState: ${w.SubState}` : (w.message || 'Watcher'); break; } - case 'timesheet': { - const t = status.timesheet || {}; - setBadge(badge, t.available ? 'OK' : 'โ', t.available ? 'ok' : 'neutral'); - body.textContent = t.available - ? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)` - : (t.message || 'No data'); - break; - } - case 'log': { + case 'timesheet': { + const t = status.timesheet || {}; + setBadge(badge, t.available ? 'OK' : 'โ', t.available ? 'ok' : 'neutral'); + body.textContent = t.available + ? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)` + : (t.message || 'No data'); + break; + } + case 'systemResources': { + const resources = status.systemResources || {}; + const cpuPct = numberOrNull(resources.cpu?.usagePct); + const memoryPct = numberOrNull(resources.memory?.usedPct); + const storagePct = numberOrNull(resources.storage?.usedPct); + const maxPct = Math.max(cpuPct ?? 0, memoryPct ?? 0, storagePct ?? 0); + setBadge(badge, maxPct >= 90 ? 'High' : 'OK', maxPct >= 90 ? 'error' : maxPct >= 75 ? 'warn' : 'ok'); + body.innerHTML = ` + ${resourceRow('CPU', cpuPct, resources.cpu?.cores ? `${resources.cpu.cores} cores` : '')} + ${resourceRow('RAM', memoryPct, formatBytes(resources.memory?.usedBytes, resources.memory?.totalBytes))} + ${resourceRow('Disk', storagePct, formatBytes(resources.storage?.usedBytes, resources.storage?.totalBytes))}`; + break; + } + case 'log': { const info = (status.scripts || {})[widget.logKey] || {}; const label = info.label || widget.logKey; const stale = info.stale; @@ -245,14 +259,46 @@ function updateWidgetCard(widget, status) { body.textContent = `Unknown widget type: ${type}`; setBadge(badge, '?', 'neutral'); } -} - +} + function setBadge(el, text, tone) { if (!el) return; el.textContent = text; el.className = `badge ${tone} widget-badge`; } +function numberOrNull(value) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function formatPct(value) { + return value == null ? 'โ' : `${Math.round(value)}%`; +} + +function formatBytes(used, total) { + const usedNum = Number(used); + const totalNum = Number(total); + if (!Number.isFinite(usedNum) || !Number.isFinite(totalNum) || totalNum <= 0) return ''; + return `${bytesToGiB(usedNum)} / ${bytesToGiB(totalNum)}`; +} + +function bytesToGiB(bytes) { + return `${(bytes / 1073741824).toFixed(1)} GiB`; +} + +function resourceRow(label, pct, detail) { + const width = Math.max(0, Math.min(100, pct ?? 0)); + return ` +