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

@@ -0,0 +1,10 @@
Generated from npm package chart.js.
The MIT License (MIT)
Copyright (c) 2014-2024 Chart.js Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,4 @@
Generated from npm package tabulator-tables.
The MIT License (MIT)
Copyright (c) 2015-2026 Oli Folkerd

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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() {

View File

@@ -139,6 +139,12 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
t.Fatalf("overview page should expose the top URLs block")
}
if !strings.Contains(recorder.Body.String(), `/assets/chartjs/chart.umd.min.js`) {
t.Fatalf("overview page should load local chart.js assets")
}
if !strings.Contains(recorder.Body.String(), `function mountDashboardChart(key, canvasId, config)`) {
t.Fatalf("overview page should render the Chart.js dashboard helpers")
}
if !strings.Contains(recorder.Body.String(), "Loading…") {
t.Fatalf("overview page should render stable loading placeholders")
}
@@ -218,6 +224,13 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
t.Fatalf("tabulator asset should be served locally: status=%d len=%d", recorder.Code, recorder.Body.Len())
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/assets/chartjs/chart.umd.min.js", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || recorder.Body.Len() == 0 {
t.Fatalf("chart.js asset should be served locally: status=%d len=%d", recorder.Code, recorder.Body.Len())
}
recorder = httptest.NewRecorder()
request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil)
handler.ServeHTTP(recorder, request)