/* * AdminRender — turns admin-canvas JSON into a canvas of AdminWindows. * * A trimmed sibling of digest-engine/render/digest-canvas-sdk/render.js: no * section-list/detail-level resolution (one flat {"windows": [...]} document, not * three sections at two detail levels) and no globe kind (nothing here is a lat/lon * marker). Same degrade-instead-of-throw philosophy as the digest, applied to four * new kinds instead of one — admin-canvas/server.py already rejects malformed * writes before they ever reach output/latest.json, but this is the second, * independent line of defense on the read side: * * - unparseable input -> a
dump of the raw text
* - not a {"windows": [...]} -> a dump of whatever was there instead
* - a window that throws -> a dump of that window, siblings still render
* - a chart/stat with bad data -> the same per-window dump, not a broken canvas
*
* A blank page is the one outcome that must never happen — same rule as the digest.
*/
(function (global) {
'use strict';
var SVG_NS = 'http://www.w3.org/2000/svg';
// Mirrors admin-canvas/server.py's SRC_RE exactly. This is defense in depth, not
// the primary check — the server already refuses to write a 'show' payload whose
// image/video src doesn't match this shape — but a page that trusted the server
// unconditionally would have no second line of defense if that check ever
// regressed. src is always relative to output/, added below.
var SRC_RE = /^media\/[A-Za-z0-9_.-]+$/;
var TREND_CLASSES = { up: 'up', down: 'down', flat: 'flat' };
function rawDump(container, value, note) {
var pre = document.createElement('pre');
pre.className = 'admin-canvas-raw';
var text = typeof value === 'string' ? value : safeStringify(value);
pre.textContent = (note ? note + '\n\n' : '') + text;
container.appendChild(pre);
return pre;
}
function safeStringify(value) {
try {
return JSON.stringify(value, null, 2);
} catch (err) {
return String(value);
}
}
function parse(input) {
if (typeof input !== 'string') { return input; }
try {
return JSON.parse(input);
} catch (err) {
return { __unparseable: input };
}
}
function formatStatValue(value) {
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
return value === undefined || value === null ? '—' : String(value);
}
function renderStatWindow(container, win) {
var content = win.content || {};
var mount = document.createElement('div');
mount.className = 'admin-stat';
var valueLine = document.createElement('div');
var valueSpan = document.createElement('span');
valueSpan.className = 'admin-stat-value admin-glow';
valueSpan.textContent = formatStatValue(content.value);
valueLine.appendChild(valueSpan);
if (content.unit) {
var unitSpan = document.createElement('span');
unitSpan.className = 'admin-stat-unit';
unitSpan.textContent = String(content.unit);
valueLine.appendChild(unitSpan);
}
if (content.trend) {
var trend = String(content.trend).toLowerCase();
var arrow = trend === 'up' ? '▲' : trend === 'down' ? '▼' : '▬';
var trendSpan = document.createElement('span');
trendSpan.className = 'admin-stat-trend-' + (TREND_CLASSES[trend] || 'flat');
trendSpan.textContent = ' ' + arrow;
valueLine.appendChild(trendSpan);
}
mount.appendChild(valueLine);
if (content.label) {
var label = document.createElement('div');
label.className = 'admin-stat-label';
label.textContent = String(content.label);
mount.appendChild(label);
}
return global.AdminWindow.open({
title: win.title || '',
content: mount,
container: container,
variant: 'stat',
x: win.x,
y: win.y,
w: win.w,
h: win.h
});
}
function renderMediaWindow(container, win) {
var content = win.content || {};
var src = content.src;
if (typeof src !== 'string' || !SRC_RE.test(src)) {
throw new Error((win.kind || 'media') + " window needs content.src matching 'media/'");
}
// admin-web (nginx) serves the shared output/ volume this service writes into
// under the 'output/' path, same convention as digest-web's output/latest.json
// — the JSON only ever names paths relative to that directory.
var el = document.createElement(win.kind === 'video' ? 'video' : 'img');
el.className = 'admin-media';
el.src = 'output/' + src;
if (win.kind === 'video') {
el.controls = true;
el.muted = content.muted !== false;
el.loop = !!content.loop;
el.autoplay = content.autoplay !== false;
el.playsInline = true;
} else {
el.alt = content.alt || win.title || '';
}
return global.AdminWindow.open({
title: win.title || '',
content: el,
container: container,
variant: win.kind,
x: win.x,
y: win.y,
w: win.w,
h: win.h
});
}
function renderBarChart(mount, content) {
var points = Array.isArray(content.points) ? content.points : [];
if (!points.length) { throw new Error('bar chart has no points'); }
var values = points.map(function (p) { return Number(p && p.value); });
if (values.some(function (v) { return !isFinite(v); })) {
throw new Error('bar chart has a non-numeric point value');
}
var w = 100, h = 56, padTop = 6, padBottom = 14, gap = 3;
var barW = (w - gap * (points.length + 1)) / points.length;
var chartH = h - padTop - padBottom;
var max = Math.max.apply(null, values.concat([0]));
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.setAttribute('class', 'admin-chart-svg');
svg.setAttribute('role', 'img');
points.forEach(function (p, i) {
var value = values[i];
var barH = max > 0 ? (value / max) * chartH : 0;
var x = gap + i * (barW + gap);
var y = padTop + (chartH - barH);
var rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('class', 'admin-chart-bar');
rect.setAttribute('x', x);
rect.setAttribute('y', y);
rect.setAttribute('width', barW);
rect.setAttribute('height', Math.max(barH, 0.5));
svg.appendChild(rect);
var valueLabel = document.createElementNS(SVG_NS, 'text');
valueLabel.setAttribute('class', 'admin-chart-bar-value');
valueLabel.setAttribute('x', x + barW / 2);
valueLabel.setAttribute('y', Math.max(y - 1.5, 5));
valueLabel.setAttribute('text-anchor', 'middle');
valueLabel.textContent = formatStatValue(value);
svg.appendChild(valueLabel);
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('class', 'admin-chart-bar-label');
label.setAttribute('x', x + barW / 2);
label.setAttribute('y', h - 3);
label.setAttribute('text-anchor', 'middle');
label.textContent = String((p && p.label) || '');
svg.appendChild(label);
});
mount.appendChild(svg);
}
function renderSparkline(mount, content) {
var values = (Array.isArray(content.points) ? content.points : []).map(Number);
if (!values.length || values.some(function (v) { return !isFinite(v); })) {
throw new Error('sparkline has no valid numeric points');
}
var w = 100, h = 40, pad = 3;
var min = Math.min.apply(null, values);
var max = Math.max.apply(null, values);
var range = max - min || 1;
var coords = values.map(function (v, i) {
var x = values.length > 1 ? (i / (values.length - 1)) * (w - pad * 2) + pad : w / 2;
var y = h - pad - ((v - min) / range) * (h - pad * 2);
return [x, y];
});
var linePath = coords.map(function (c, i) {
return (i === 0 ? 'M' : 'L') + c[0].toFixed(2) + ',' + c[1].toFixed(2);
}).join(' ');
var last = coords[coords.length - 1];
var first = coords[0];
var fillPath = linePath +
' L' + last[0].toFixed(2) + ',' + (h - pad) +
' L' + first[0].toFixed(2) + ',' + (h - pad) + ' Z';
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h);
svg.setAttribute('class', 'admin-chart-svg');
svg.setAttribute('role', 'img');
var defs = document.createElementNS(SVG_NS, 'defs');
var gradient = document.createElementNS(SVG_NS, 'linearGradient');
gradient.setAttribute('id', 'admin-chart-sparkline-gradient');
gradient.setAttribute('x1', '0');
gradient.setAttribute('x2', '0');
gradient.setAttribute('y1', '0');
gradient.setAttribute('y2', '1');
var stop1 = document.createElementNS(SVG_NS, 'stop');
stop1.setAttribute('offset', '0%');
stop1.style.stopColor = 'var(--admin-accent)';
stop1.style.stopOpacity = '0.9';
var stop2 = document.createElementNS(SVG_NS, 'stop');
stop2.setAttribute('offset', '100%');
stop2.style.stopColor = 'var(--admin-accent)';
stop2.style.stopOpacity = '0';
gradient.appendChild(stop1);
gradient.appendChild(stop2);
defs.appendChild(gradient);
svg.appendChild(defs);
var fill = document.createElementNS(SVG_NS, 'path');
fill.setAttribute('class', 'admin-chart-sparkline-fill');
fill.setAttribute('d', fillPath);
svg.appendChild(fill);
var line = document.createElementNS(SVG_NS, 'path');
line.setAttribute('class', 'admin-chart-sparkline-path');
line.setAttribute('d', linePath);
svg.appendChild(line);
mount.appendChild(svg);
}
function renderChartWindow(container, win) {
var content = win.content || {};
var mount = document.createElement('div');
mount.className = 'admin-chart';
if (content.type === 'sparkline') {
renderSparkline(mount, content);
} else {
renderBarChart(mount, content);
}
if (content.unit) {
var caption = document.createElement('p');
caption.className = 'admin-window-body-caption';
caption.textContent = content.unit;
mount.appendChild(caption);
}
return global.AdminWindow.open({
title: win.title || '',
content: mount,
container: container,
variant: 'chart',
x: win.x,
y: win.y,
w: win.w,
h: win.h
});
}
function renderWindow(container, win) {
if (!win || typeof win !== 'object') {
return rawDump(container, win, '// malformed window');
}
switch (win.kind) {
case 'stat':
return renderStatWindow(container, win);
case 'image':
case 'video':
return renderMediaWindow(container, win);
case 'chart':
return renderChartWindow(container, win);
default:
// No kind (or an unrecognized one, kept here as a soft landing rather than
// a hard failure) -> plain markdown-ish content, same as a digest window.
return global.AdminWindow.open({
title: win.title || '',
content: win.content,
container: container,
x: win.x,
y: win.y,
w: win.w,
h: win.h
});
}
}
var AdminRender = {
render: function (container, input) {
container.innerHTML = '';
container.classList.add('admin-canvas');
var parsed = parse(input);
if (parsed && parsed.__unparseable !== undefined) {
rawDump(container, parsed.__unparseable, '// admin canvas JSON did not parse');
return;
}
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.windows)) {
rawDump(container, parsed, '// expected {"windows": [...]}, got something else');
return;
}
parsed.windows.forEach(function (win) {
try {
renderWindow(container, win);
} catch (err) {
if (global.console) { global.console.warn('admin-canvas: window fell back to plain text', err); }
rawDump(container, win, '// window failed to render: ' + err);
}
});
},
// Convenience used by canvas.html: fetch, render, and put the failure on
// screen rather than only in the console if the fetch itself fails.
load: function (container, url) {
return global.fetch(url, { cache: 'no-store' })
.then(function (response) {
if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); }
return response.text();
})
.then(function (text) {
AdminRender.render(container, text);
})
.catch(function (err) {
container.innerHTML = '';
container.classList.add('admin-canvas');
rawDump(container, String(err), '// could not load ' + url);
});
}
};
global.AdminRender = AdminRender;
})(window);