You've already forked caddy-opnsense-blocker
Manage frontend vendors with npm and use Chart.js
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
/data/
|
/data/
|
||||||
/caddy-opnsense-blocker
|
/caddy-opnsense-blocker
|
||||||
|
/node_modules/
|
||||||
/result
|
/result
|
||||||
/result-*
|
/result-*
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -8,7 +8,7 @@
|
|||||||
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
|
- One heuristic profile per log source, so different applications can have different rules while sharing the same OPNsense destination alias.
|
||||||
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
|
- Persistent SQLite state for events, IP states, investigations, decisions, backend actions, and source offsets.
|
||||||
- Lightweight web UI with a Pi-hole-style dashboard, source-colored activity charts, split bot/non-bot leaderboards, a paginated requests log with collapsible filters and clickable column sorting, IP detail pages, decision history, and full request history per address.
|
- Lightweight web UI with a Pi-hole-style dashboard, source-colored activity charts, split bot/non-bot leaderboards, a paginated requests log with collapsible filters and clickable column sorting, IP detail pages, decision history, and full request history per address.
|
||||||
- The requests log ships with vendored Tabulator assets served locally by the daemon, so the UI stays self-contained and does not depend on a CDN.
|
- The dashboard and requests log ship with npm-managed vendored Chart.js and Tabulator assets served locally by the daemon, so the UI stays self-contained and does not depend on a CDN.
|
||||||
- Background investigation workers that fill in missing cached intelligence without slowing down page loads.
|
- Background investigation workers that fill in missing cached intelligence without slowing down page loads.
|
||||||
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
|
- Manual `Block`, `Unblock`, `Clear override`, and `Refresh investigation` actions from the UI or the HTTP API.
|
||||||
- Optional OPNsense integration; the daemon also works in review-only mode.
|
- Optional OPNsense integration; the daemon also works in review-only mode.
|
||||||
@@ -152,6 +152,15 @@ Key ideas:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
Frontend third-party assets are managed with npm and then vendored into `internal/web/assets/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run vendor:sync
|
||||||
|
```
|
||||||
|
|
||||||
|
This refreshes the locally served Chart.js and Tabulator files from `node_modules/`.
|
||||||
|
|
||||||
Run the test suite:
|
Run the test suite:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
10
internal/web/assets/chartjs/LICENSE-chartjs
Normal file
10
internal/web/assets/chartjs/LICENSE-chartjs
Normal 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.
|
||||||
15
internal/web/assets/chartjs/chart.umd.min.js
vendored
Normal file
15
internal/web/assets/chartjs/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
|||||||
|
Generated from npm package tabulator-tables.
|
||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2015-2026 Oli Folkerd
|
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
@@ -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-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; }
|
.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; }
|
.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-shell { min-height: 18rem; }
|
||||||
.activity-axis { fill: #64748b; font-size: 12px; }
|
.activity-canvas-shell { position: relative; min-height: 18rem; width: 100%; }
|
||||||
.activity-gridline { stroke: #1e293b; stroke-width: 1; }
|
.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-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-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-chart-shell { width: min(100%, 220px); aspect-ratio: 1; 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 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 strong { font-size: 1.6rem; }
|
||||||
.donut-hole span { font-size: .8rem; color: #94a3b8; }
|
.donut-hole span { font-size: .8rem; color: #94a3b8; }
|
||||||
.legend-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
|
.legend-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
|
||||||
@@ -625,9 +626,18 @@ const overviewHTML = `<!doctype html>
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script src="/assets/chartjs/chart.umd.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const recentHours = 24;
|
const recentHours = 24;
|
||||||
const sourcePalette = ['#38bdf8', '#22c55e', '#f59e0b', '#a78bfa', '#f97316', '#14b8a6', '#ec4899', '#60a5fa', '#84cc16', '#ef4444'];
|
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) {
|
function escapeHtml(value) {
|
||||||
return String(value ?? '')
|
return String(value ?? '')
|
||||||
@@ -664,6 +674,51 @@ const overviewHTML = `<!doctype html>
|
|||||||
return sourcePalette[Math.abs(hash) % sourcePalette.length];
|
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) {
|
function botVisual(bot) {
|
||||||
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
|
const candidate = String((bot || {}).provider_id || (bot || {}).name || '').toLowerCase();
|
||||||
const catalog = [
|
const catalog = [
|
||||||
@@ -796,6 +851,7 @@ const overviewHTML = `<!doctype html>
|
|||||||
const chart = document.getElementById('activity-chart');
|
const chart = document.getElementById('activity-chart');
|
||||||
const legend = document.getElementById('activity-legend');
|
const legend = document.getElementById('activity-legend');
|
||||||
if (!buckets.length) {
|
if (!buckets.length) {
|
||||||
|
destroyDashboardChart('activity');
|
||||||
chart.className = 'chart-placeholder';
|
chart.className = 'chart-placeholder';
|
||||||
chart.innerHTML = 'No activity in the selected window.';
|
chart.innerHTML = 'No activity in the selected window.';
|
||||||
legend.innerHTML = '';
|
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]));
|
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('');
|
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.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 host = document.getElementById(targetId);
|
||||||
const visibleItems = (Array.isArray(items) ? items : []).filter(item => Number(item.events || 0) > 0);
|
const visibleItems = (Array.isArray(items) ? items : []).filter(item => Number(item.events || 0) > 0);
|
||||||
if (!visibleItems.length) {
|
if (!visibleItems.length) {
|
||||||
|
destroyDashboardChart(chartKey);
|
||||||
host.className = 'donut-shell chart-placeholder';
|
host.className = 'donut-shell chart-placeholder';
|
||||||
host.innerHTML = 'No matching requests in the selected window.';
|
host.innerHTML = 'No matching requests in the selected window.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const total = visibleItems.reduce((sum, item) => sum + Number(item.events || 0), 0);
|
const total = visibleItems.reduce((sum, item) => sum + Number(item.events || 0), 0);
|
||||||
let offset = 0;
|
const canvasID = targetId + '-canvas';
|
||||||
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;
|
|
||||||
});
|
|
||||||
host.className = 'donut-shell';
|
host.className = 'donut-shell';
|
||||||
host.innerHTML = [
|
host.innerHTML = [
|
||||||
'<div class="donut-grid">',
|
'<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 class="donut-hole"><strong>' + escapeHtml(total) + '</strong><span>' + escapeHtml(totalLabel) + '</span></div>',
|
||||||
' </div>',
|
' </div>',
|
||||||
' <ul class="legend-list">',
|
' <ul class="legend-list">',
|
||||||
@@ -877,17 +956,50 @@ const overviewHTML = `<!doctype html>
|
|||||||
' </ul>',
|
' </ul>',
|
||||||
'</div>'
|
'</div>'
|
||||||
].join('');
|
].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) {
|
function renderMethods(data) {
|
||||||
const colors = { GET: '#22c55e', POST: '#f59e0b', HEAD: '#38bdf8', PUT: '#a78bfa', DELETE: '#ef4444', PATCH: '#14b8a6', OPTIONS: '#f97316', OTHER: '#64748b' };
|
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 }));
|
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) {
|
function renderBots(data) {
|
||||||
const colors = { known: '#2563eb', possible: '#f59e0b', other: '#475569' };
|
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() {
|
async function refresh() {
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ func TestHandlerServesOverviewAndManualActions(t *testing.T) {
|
|||||||
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
|
if !strings.Contains(recorder.Body.String(), "Top URLs by events") {
|
||||||
t.Fatalf("overview page should expose the top URLs block")
|
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…") {
|
if !strings.Contains(recorder.Body.String(), "Loading…") {
|
||||||
t.Fatalf("overview page should render stable loading placeholders")
|
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())
|
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()
|
recorder = httptest.NewRecorder()
|
||||||
request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil)
|
request = httptest.NewRequest(http.MethodGet, "/ips/203.0.113.10", nil)
|
||||||
handler.ServeHTTP(recorder, request)
|
handler.ServeHTTP(recorder, request)
|
||||||
|
|||||||
41
package-lock.json
generated
Normal file
41
package-lock.json
generated
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "caddy-opnsense-blocker-web-assets",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "caddy-opnsense-blocker-web-assets",
|
||||||
|
"devDependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"tabulator-tables": "^6.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tabulator-tables": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-Lxh+leFNoBo/Yyr4USs6gxqbfo8anYUaUMmoT91pfVLtoUgl/dE+qV7ahnFrKVMCYYqGG33aIMPR7FzpPBaNYA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "caddy-opnsense-blocker-web-assets",
|
||||||
|
"private": true,
|
||||||
|
"description": "Frontend vendor asset management for caddy-opnsense-blocker",
|
||||||
|
"packageManager": "npm@10.9.4",
|
||||||
|
"scripts": {
|
||||||
|
"vendor:sync": "node scripts/sync-web-vendors.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"tabulator-tables": "^6.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
scripts/sync-web-vendors.mjs
Normal file
49
scripts/sync-web-vendors.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
async function copyAsset(source, destination, banner) {
|
||||||
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
|
if (!banner) {
|
||||||
|
await cp(source, destination);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = await readFile(source, 'utf8');
|
||||||
|
await writeFile(destination, `${banner}\n${content}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await copyAsset(
|
||||||
|
resolve(rootDir, 'node_modules/tabulator-tables/dist/js/tabulator.min.js'),
|
||||||
|
resolve(rootDir, 'internal/web/assets/tabulator/tabulator.min.js'),
|
||||||
|
'/* Generated from npm package tabulator-tables. Run npm run vendor:sync to refresh. */',
|
||||||
|
);
|
||||||
|
await copyAsset(
|
||||||
|
resolve(rootDir, 'node_modules/tabulator-tables/dist/css/tabulator_midnight.min.css'),
|
||||||
|
resolve(rootDir, 'internal/web/assets/tabulator/tabulator_midnight.min.css'),
|
||||||
|
'/* Generated from npm package tabulator-tables. Run npm run vendor:sync to refresh. */',
|
||||||
|
);
|
||||||
|
await copyAsset(
|
||||||
|
resolve(rootDir, 'node_modules/tabulator-tables/LICENSE'),
|
||||||
|
resolve(rootDir, 'internal/web/assets/tabulator/LICENSE-tabulator'),
|
||||||
|
'Generated from npm package tabulator-tables.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await copyAsset(
|
||||||
|
resolve(rootDir, 'node_modules/chart.js/dist/chart.umd.min.js'),
|
||||||
|
resolve(rootDir, 'internal/web/assets/chartjs/chart.umd.min.js'),
|
||||||
|
'/* Generated from npm package chart.js. Run npm run vendor:sync to refresh. */',
|
||||||
|
);
|
||||||
|
await copyAsset(
|
||||||
|
resolve(rootDir, 'node_modules/chart.js/LICENSE.md'),
|
||||||
|
resolve(rootDir, 'internal/web/assets/chartjs/LICENSE-chartjs'),
|
||||||
|
'Generated from npm package chart.js.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user