2

Manage frontend vendors with npm and use Chart.js

This commit is contained in:
2026-03-12 21:57:47 +01:00
parent d4fe3f381d
commit 735ae52905
12 changed files with 319 additions and 53 deletions

View File

@@ -462,13 +462,14 @@ const overviewHTML = `<!doctype html>
.legend-chip { display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .55rem; border: 1px solid #334155; border-radius: 999px; background: #0b1220; font-size: .82rem; color: #cbd5e1; }
.legend-dot { width: .7rem; height: .7rem; border-radius: 999px; display: inline-block; }
.chart-placeholder { min-height: 15rem; display: flex; align-items: center; justify-content: center; color: #64748b; background: linear-gradient(180deg, rgba(15, 23, 42, .85), rgba(2, 6, 23, .6)); border: 1px dashed #334155; border-radius: .75rem; }
.activity-shell svg { width: 100%; height: auto; display: block; }
.activity-axis { fill: #64748b; font-size: 12px; }
.activity-gridline { stroke: #1e293b; stroke-width: 1; }
.activity-shell { min-height: 18rem; }
.activity-canvas-shell { position: relative; min-height: 18rem; width: 100%; }
.activity-canvas-shell canvas { display: block; width: 100% !important; height: 18rem !important; }
.donut-shell { min-height: 15rem; display: flex; align-items: center; justify-content: center; }
.donut-grid { display: grid; grid-template-columns: minmax(160px, 220px) 1fr; gap: 1rem; align-items: center; width: 100%; }
.donut { width: min(100%, 220px); aspect-ratio: 1; border-radius: 999px; position: relative; margin: 0 auto; }
.donut-hole { position: absolute; inset: 22%; border-radius: 999px; background: #0b1220; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; box-shadow: inset 0 0 0 1px #334155; }
.donut-chart-shell { width: min(100%, 220px); aspect-ratio: 1; position: relative; margin: 0 auto; }
.donut-chart-shell canvas { display: block; width: 100% !important; height: 100% !important; }
.donut-hole { position: absolute; inset: 22%; border-radius: 999px; background: #0b1220; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; box-shadow: inset 0 0 0 1px #334155; pointer-events: none; }
.donut-hole strong { font-size: 1.6rem; }
.donut-hole span { font-size: .8rem; color: #94a3b8; }
.legend-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
@@ -625,9 +626,18 @@ const overviewHTML = `<!doctype html>
</section>
</section>
</main>
<script src="/assets/chartjs/chart.umd.min.js"></script>
<script>
const recentHours = 24;
const sourcePalette = ['#38bdf8', '#22c55e', '#f59e0b', '#a78bfa', '#f97316', '#14b8a6', '#ec4899', '#60a5fa', '#84cc16', '#ef4444'];
const dashboardCharts = { activity: null, methods: null, bots: null };
if (typeof Chart !== 'undefined') {
Chart.defaults.color = '#cbd5e1';
Chart.defaults.borderColor = '#1e293b';
Chart.defaults.font.family = 'system-ui, sans-serif';
Chart.defaults.animation = false;
}
function escapeHtml(value) {
return String(value ?? '')
@@ -664,6 +674,51 @@ const overviewHTML = `<!doctype html>
return sourcePalette[Math.abs(hash) % sourcePalette.length];
}
function destroyDashboardChart(key) {
if (dashboardCharts[key]) {
dashboardCharts[key].destroy();
dashboardCharts[key] = null;
}
}
function mountDashboardChart(key, canvasId, config) {
destroyDashboardChart(key);
if (typeof Chart === 'undefined') {
return;
}
const canvas = document.getElementById(canvasId);
if (!canvas) {
return;
}
dashboardCharts[key] = new Chart(canvas, config);
}
function formatRequestCount(value) {
const count = Number(value || 0);
return count + (count === 1 ? ' request' : ' requests');
}
function formatBucketTooltipLabel(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
return date.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatPercent(value, total) {
const ratio = total > 0 ? (Number(value || 0) / total) * 100 : 0;
if (!Number.isFinite(ratio)) {
return '0%';
}
return (ratio >= 10 || Number.isInteger(ratio) ? ratio.toFixed(0) : ratio.toFixed(1)) + '%';
}
function showChartLoadError(host, message) {
host.className = 'chart-placeholder';
host.innerHTML = escapeHtml(message);
}
function botVisual(bot) {
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
const catalog = [
@@ -796,6 +851,7 @@ const overviewHTML = `<!doctype html>
const chart = document.getElementById('activity-chart');
const legend = document.getElementById('activity-legend');
if (!buckets.length) {
destroyDashboardChart('activity');
chart.className = 'chart-placeholder';
chart.innerHTML = 'No activity in the selected window.';
legend.innerHTML = '';
@@ -811,65 +867,88 @@ const overviewHTML = `<!doctype html>
}
const orderedSources = [...totalsBySource.entries()].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
legend.innerHTML = orderedSources.map(entry => '<span class="legend-chip"><span class="legend-dot" style="background:' + colorForSource(entry[0]) + '"></span>' + escapeHtml(entry[0]) + '<span class="muted">' + escapeHtml(String(entry[1])) + '</span></span>').join('');
const width = 1100;
const height = 260;
const chartHeight = 190;
const chartTop = 18;
const chartLeft = 18;
const chartWidth = width - (chartLeft * 2);
const step = chartWidth / Math.max(buckets.length, 1);
const barWidth = Math.max(2, step - 1);
const lines = [0.25, 0.5, 0.75, 1].map(fraction => {
const y = chartTop + chartHeight - (chartHeight * fraction);
return '<line class="activity-gridline" x1="' + chartLeft + '" y1="' + y + '" x2="' + (width - chartLeft) + '" y2="' + y + '"></line>';
}).join('');
const bars = buckets.map((bucket, index) => {
const x = chartLeft + (index * step);
let currentY = chartTop + chartHeight;
return (Array.isArray(bucket.sources) ? bucket.sources : []).map(source => {
const segmentHeight = maxTotal > 0 ? Math.max((Number(source.events || 0) / maxTotal) * chartHeight, 0) : 0;
currentY -= segmentHeight;
if (segmentHeight <= 0) {
return '';
}
return '<rect x="' + x.toFixed(2) + '" y="' + currentY.toFixed(2) + '" width="' + barWidth.toFixed(2) + '" height="' + segmentHeight.toFixed(2) + '" rx="1.5" fill="' + colorForSource(source.source_name) + '"><title>' + escapeHtml((source.source_name || 'unknown') + ': ' + source.events + ' request(s)') + '</title></rect>';
}).join('');
}).join('');
const tickStep = Math.max(1, Math.floor(buckets.length / 6));
const ticks = buckets.map((bucket, index) => {
if (index % tickStep !== 0 && index !== buckets.length - 1) {
return '';
}
const x = chartLeft + (index * step) + (barWidth / 2);
return '<text class="activity-axis" x="' + x.toFixed(2) + '" y="' + (height - 10) + '" text-anchor="middle">' + escapeHtml(formatBucketLabel(bucket.bucket_start)) + '</text>';
}).join('');
chart.className = 'activity-shell';
chart.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" role="img" aria-label="Activity histogram">' + lines + bars + ticks + '</svg>';
chart.innerHTML = '<div class="activity-canvas-shell"><canvas id="activity-canvas" aria-label="Activity histogram"></canvas></div>';
if (typeof Chart === 'undefined') {
showChartLoadError(chart, 'Chart.js failed to load.');
return;
}
const labels = buckets.map(bucket => formatBucketLabel(bucket.bucket_start));
const tooltipLabels = buckets.map(bucket => formatBucketTooltipLabel(bucket.bucket_start));
const datasets = orderedSources.map(entry => {
const sourceName = entry[0];
return {
label: sourceName,
data: buckets.map(bucket => {
const source = (Array.isArray(bucket.sources) ? bucket.sources : []).find(item => item.source_name === sourceName);
return source ? Number(source.events || 0) : 0;
}),
backgroundColor: colorForSource(sourceName),
borderColor: colorForSource(sourceName),
borderWidth: 0,
borderRadius: 3,
borderSkipped: false,
stack: 'activity',
maxBarThickness: 28,
};
});
mountDashboardChart('activity', 'activity-canvas', {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title(items) {
return items.length ? tooltipLabels[items[0].dataIndex] || '' : '';
},
label(context) {
return String(context.dataset.label || 'unknown') + ': ' + formatRequestCount(context.parsed.y);
},
footer(items) {
const total = items.reduce((sum, item) => sum + Number(item.parsed.y || 0), 0);
return 'Total: ' + formatRequestCount(total);
},
},
},
},
scales: {
x: {
stacked: true,
grid: { display: false },
ticks: { color: '#64748b', autoSkip: true, maxTicksLimit: 8, maxRotation: 0, minRotation: 0 },
},
y: {
stacked: true,
beginAtZero: true,
ticks: { color: '#64748b', precision: 0 },
grid: { color: '#1e293b' },
},
},
},
});
}
function renderDonut(targetId, items, colors, totalLabel) {
function renderDonut(targetId, chartKey, items, colors, totalLabel) {
const host = document.getElementById(targetId);
const visibleItems = (Array.isArray(items) ? items : []).filter(item => Number(item.events || 0) > 0);
if (!visibleItems.length) {
destroyDashboardChart(chartKey);
host.className = 'donut-shell chart-placeholder';
host.innerHTML = 'No matching requests in the selected window.';
return;
}
const total = visibleItems.reduce((sum, item) => sum + Number(item.events || 0), 0);
let offset = 0;
const gradientParts = visibleItems.map(item => {
const key = item.key || item.method;
const color = colors[key] || '#64748b';
const slice = total > 0 ? (Number(item.events || 0) / total) * 360 : 0;
const part = color + ' ' + offset + 'deg ' + (offset + slice) + 'deg';
offset += slice;
return part;
});
const canvasID = targetId + '-canvas';
host.className = 'donut-shell';
host.innerHTML = [
'<div class="donut-grid">',
' <div class="donut" style="background: conic-gradient(' + gradientParts.join(', ') + ')">',
' <div class="donut-chart-shell">',
' <canvas id="' + escapeHtml(canvasID) + '" aria-label="' + escapeHtml(targetId) + ' chart"></canvas>',
' <div class="donut-hole"><strong>' + escapeHtml(total) + '</strong><span>' + escapeHtml(totalLabel) + '</span></div>',
' </div>',
' <ul class="legend-list">',
@@ -877,17 +956,50 @@ const overviewHTML = `<!doctype html>
' </ul>',
'</div>'
].join('');
if (typeof Chart === 'undefined') {
showChartLoadError(host, 'Chart.js failed to load.');
return;
}
mountDashboardChart(chartKey, canvasID, {
type: 'doughnut',
data: {
labels: visibleItems.map(item => item.label || item.method || item.key || 'Other'),
datasets: [{
data: visibleItems.map(item => Number(item.events || 0)),
backgroundColor: visibleItems.map(item => colors[item.key || item.method] || '#64748b'),
borderColor: '#0b1220',
borderWidth: 2,
hoverOffset: 8,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context) {
const value = Number(context.parsed || 0);
return String(context.label || 'Other') + ': ' + formatRequestCount(value) + ' (' + formatPercent(value, total) + ')';
},
},
},
},
},
});
}
function renderMethods(data) {
const colors = { GET: '#22c55e', POST: '#f59e0b', HEAD: '#38bdf8', PUT: '#a78bfa', DELETE: '#ef4444', PATCH: '#14b8a6', OPTIONS: '#f97316', OTHER: '#64748b' };
const items = (Array.isArray(data.methods) ? data.methods : []).map(item => ({ method: item.method || 'OTHER', label: item.method || 'OTHER', events: item.events || 0 }));
renderDonut('methods-chart', items, colors, 'requests');
renderDonut('methods-chart', 'methods', items, colors, 'requests');
}
function renderBots(data) {
const colors = { known: '#2563eb', possible: '#f59e0b', other: '#475569' };
renderDonut('bots-chart', Array.isArray(data.bots) ? data.bots : [], colors, 'requests');
renderDonut('bots-chart', 'bots', Array.isArray(data.bots) ? data.bots : [], colors, 'requests');
}
async function refresh() {