This commit is contained in:
@@ -723,3 +723,34 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.45;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,14 +24,20 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"widgets": [
|
"widgets": [
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
"id": "authoring-queue",
|
"id": "authoring-queue",
|
||||||
|
|
||||||
"type": "queue",
|
"type": "queue",
|
||||||
|
|
||||||
|
"title": "Authoring builds",
|
||||||
|
|
||||||
|
"integration": "authoring"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
"id": "authoring-detail",
|
"id": "authoring-detail",
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ const logStreams = new Map();
|
|||||||
let statusPollTimer = null;
|
let statusPollTimer = null;
|
||||||
|
|
||||||
/** Fallback when manifest API fails and static file is an older v1 without widgets. */
|
/** Fallback when manifest API fails and static file is an older v1 without widgets. */
|
||||||
const DEFAULT_WIDGETS = [
|
const DEFAULT_WIDGETS = [
|
||||||
{ id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' },
|
{ id: 'authoring-queue', type: 'queue', title: 'Authoring builds', integration: 'authoring' },
|
||||||
{ id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' },
|
{ id: 'authoring-detail', type: 'authoringDetail', title: 'Authoring detail', integration: 'authoring' },
|
||||||
{ id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' },
|
{ id: 'watcher', type: 'badge', title: 'Lima watcher', integration: 'watcher' },
|
||||||
{ id: 'timesheet-summary', type: 'timesheet', title: 'Hours this week', integration: 'timesheet' },
|
{ id: 'system-resources', type: 'systemResources', title: 'System resources', integration: 'resources' },
|
||||||
{ id: 'scripts-health', type: 'log', title: 'Script logs', integration: 'scripts', logKey: 'orgWebBuild' },
|
{ 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: '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' },
|
{ 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');
|
body.textContent = w.SubState ? `SubState: ${w.SubState}` : (w.message || 'Watcher');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'timesheet': {
|
case 'timesheet': {
|
||||||
const t = status.timesheet || {};
|
const t = status.timesheet || {};
|
||||||
setBadge(badge, t.available ? 'OK' : '—', t.available ? 'ok' : 'neutral');
|
setBadge(badge, t.available ? 'OK' : '—', t.available ? 'ok' : 'neutral');
|
||||||
body.textContent = t.available
|
body.textContent = t.available
|
||||||
? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)`
|
? `Week ${t.weekStart}: ${t.hoursWorked}h worked (${t.delta >= 0 ? '+' : ''}${t.delta}h vs contracted)`
|
||||||
: (t.message || 'No data');
|
: (t.message || 'No data');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'log': {
|
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 info = (status.scripts || {})[widget.logKey] || {};
|
||||||
const label = info.label || widget.logKey;
|
const label = info.label || widget.logKey;
|
||||||
const stale = info.stale;
|
const stale = info.stale;
|
||||||
@@ -245,14 +259,46 @@ function updateWidgetCard(widget, status) {
|
|||||||
body.textContent = `Unknown widget type: ${type}`;
|
body.textContent = `Unknown widget type: ${type}`;
|
||||||
setBadge(badge, '?', 'neutral');
|
setBadge(badge, '?', 'neutral');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(el, text, tone) {
|
function setBadge(el, text, tone) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.textContent = text;
|
el.textContent = text;
|
||||||
el.className = `badge ${tone} widget-badge`;
|
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 `
|
||||||
|
<div class="resource-row">
|
||||||
|
<div class="resource-line">
|
||||||
|
<span>${label}</span>
|
||||||
|
<span>${formatPct(pct)}${detail ? ` · ${detail}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="resource-meter"><span style="width:${width}%"></span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function safeDomId(value) {
|
function safeDomId(value) {
|
||||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ test('manifest v2 defines integrations and widgets', () => {
|
|||||||
assert.ok(manifest.integrations?.authoring);
|
assert.ok(manifest.integrations?.authoring);
|
||||||
assert.ok(Array.isArray(manifest.widgets) && manifest.widgets.length >= 3);
|
assert.ok(Array.isArray(manifest.widgets) && manifest.widgets.length >= 3);
|
||||||
assert.ok(manifest.widgets.some(w => w.id === 'scripts-health'));
|
assert.ok(manifest.widgets.some(w => w.id === 'scripts-health'));
|
||||||
|
assert.ok(manifest.widgets.some(w => w.id === 'system-resources' && w.type === 'systemResources'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manifest defines unique link and build ids', () => {
|
test('manifest defines unique link and build ids', () => {
|
||||||
@@ -155,6 +156,8 @@ test('dashboard module references manifest-driven API endpoints', () => {
|
|||||||
assert.match(dashboardJs, /data\/manifest\.json/);
|
assert.match(dashboardJs, /data\/manifest\.json/);
|
||||||
assert.match(dashboardJs, /\/api\/zone\/status/);
|
assert.match(dashboardJs, /\/api\/zone\/status/);
|
||||||
assert.match(dashboardJs, /renderWidgets/);
|
assert.match(dashboardJs, /renderWidgets/);
|
||||||
|
assert.match(dashboardJs, /systemResources/);
|
||||||
|
assert.match(dashboardJs, /resourceRow/);
|
||||||
assert.match(dashboardJs, /buildDomIds/);
|
assert.match(dashboardJs, /buildDomIds/);
|
||||||
assert.doesNotMatch(dashboardJs, /build-roam/);
|
assert.doesNotMatch(dashboardJs, /build-roam/);
|
||||||
assert.doesNotMatch(dashboardJs, /run-combined/);
|
assert.doesNotMatch(dashboardJs, /run-combined/);
|
||||||
|
|||||||
Reference in New Issue
Block a user