373 lines
13 KiB
JavaScript
373 lines
13 KiB
JavaScript
/*
|
|
* 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 <pre> dump of the raw text
|
|
* - not a {"windows": [...]} -> a <pre> dump of whatever was there instead
|
|
* - a window that throws -> a <pre> dump of that window, siblings still render
|
|
* - a chart/stat with bad data -> the same per-window <pre> 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/<filename>'");
|
|
}
|
|
|
|
// 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);
|