You've already forked caddy-opnsense-blocker
Manage frontend vendors with npm and use Chart.js
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user