Checkpoint/pages/stats/stats.html

1274 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Checkpoint Stats</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
/>
<style>
:root {
--primary-color: #4361ee;
--primary-light: rgba(67, 97, 238, 0.1);
--success-color: #2ecc71;
--success-light: rgba(46, 204, 113, 0.1);
--danger-color: #e74c3c;
--danger-light: rgba(231, 76, 60, 0.1);
--border-color: #e9ecef;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
--graph-bg: #fff;
--divider-color: #e9ecef;
--text-color: #495057;
--header-color: #212529;
--muted-color: #6c757d;
--hover-bg: #f8f9fa;
}
body {
background: #f8f9fa;
color: var(--text-color);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
line-height: 1.5;
font-size: 15px;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1,
h2,
h3 {
text-align: center;
color: var(--header-color);
font-weight: 600;
margin-top: 0;
}
h1 {
font-size: 1.8rem;
margin-bottom: 20px;
padding-bottom: 15px;
position: relative;
}
h1::after {
content: '';
display: block;
width: 80px;
height: 3px;
background: var(--primary-color);
margin: 15px auto 0;
border-radius: 2px;
}
.controls-wrapper {
background: white;
border-radius: 8px;
box-shadow: var(--card-shadow);
padding: 15px 20px;
margin: 0 0 25px;
border: 1px solid var(--border-color);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.controls-left {
display: flex;
align-items: center;
flex: 1;
}
.stats-summary {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
background: white;
padding: 8px 15px;
border-radius: 6px;
min-width: 90px;
flex: 1;
text-align: center;
}
.stat-label {
font-size: 0.8rem;
color: var(--muted-color);
margin-bottom: 3px;
white-space: nowrap;
}
.stat-value {
font-size: 1.2rem;
font-weight: 600;
}
.stat-hits .stat-value {
color: var(--primary-color);
}
.stat-passes .stat-value {
color: var(--success-color);
}
.stat-failures .stat-value {
color: var(--danger-color);
}
.controls-right {
display: flex;
align-items: center;
gap: 15px;
flex-shrink: 0;
}
.controls label {
font-weight: 500;
color: var(--header-color);
font-size: 0.9rem;
white-space: nowrap;
}
.controls select {
padding: 8px 12px;
font-size: 0.9rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: white;
cursor: pointer;
font-family: inherit;
color: var(--text-color);
min-width: 120px;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
.controls select:hover {
border-color: var(--primary-color);
}
.controls select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-light);
}
.refresh-button {
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
padding: 8px 15px;
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: background 0.2s;
white-space: nowrap;
}
.refresh-button:hover {
background: #3a56de;
}
.refresh-button svg {
width: 16px;
height: 16px;
}
.section {
margin-bottom: 40px;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.section-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--header-color);
margin: 0;
flex: 1;
}
.divider {
text-align: center;
position: relative;
margin: 40px 0;
font-size: 1rem;
color: var(--muted-color);
}
.divider::before,
.divider::after {
content: '';
display: inline-block;
width: 30%;
height: 1px;
background: var(--divider-color);
vertical-align: middle;
margin: 0 15px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: white;
border-radius: 8px;
box-shadow: var(--card-shadow);
overflow: hidden;
position: relative;
border: 1px solid var(--border-color);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.card-header {
padding: 15px;
font-weight: 600;
font-size: 1rem;
text-align: center;
color: var(--header-color);
background: #fff;
border-bottom: 1px solid var(--border-color);
}
.hits-card .card-header {
border-top: 3px solid var(--primary-color);
}
.success-card .card-header {
border-top: 3px solid var(--success-color);
}
.failure-card .card-header {
border-top: 3px solid var(--danger-color);
}
.card-body {
padding: 15px;
position: relative;
}
.graph-container {
background: var(--graph-bg);
padding: 10px;
height: 250px;
position: relative;
}
.list-container {
height: 220px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
padding: 5px 0;
}
.list-container::-webkit-scrollbar {
width: 6px;
}
.list-container::-webkit-scrollbar-track {
background: transparent;
}
.list-container::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 10px;
}
.list-item {
display: flex;
justify-content: space-between;
padding: 10px;
border-radius: 4px;
transition: background 0.2s;
}
.list-item:hover {
background: var(--hover-bg);
}
.list-item-count {
font-weight: 500;
background: var(--primary-light);
color: var(--primary-color);
padding: 2px 8px;
border-radius: 12px;
font-size: 0.85rem;
}
.no-data {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
color: var(--muted-color);
font-style: italic;
}
.note {
font-size: 0.85rem;
color: var(--muted-color);
text-align: center;
margin-top: 15px;
}
/* Loading indicator */
.loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
gap: 10px;
}
.loading-text {
font-size: 0.85rem;
color: var(--muted-color);
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid rgba(67, 97, 238, 0.2);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Fade-in-up animation for cards and stats */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
opacity: 0;
animation: fadeInUp 0.6s ease-out forwards;
}
.delay-1 {
animation-delay: 0.2s;
}
.delay-2 {
animation-delay: 0.3s;
}
.delay-3 {
animation-delay: 0.4s;
}
.delay-4 {
animation-delay: 0.5s;
}
.delay-5 {
animation-delay: 0.6s;
}
/* Skeleton shimmer for loading placeholders */
.skeleton::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, #eee 25%, #ddd 50%, #eee 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.container {
padding: 15px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 15px;
}
.controls-wrapper {
padding: 12px 15px;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: 15px;
}
.controls-left {
justify-content: center;
width: 100%;
}
.stats-summary {
justify-content: center;
width: 100%;
gap: 10px;
}
.stat-item {
min-width: 80px;
padding: 6px 12px;
}
.stat-label {
font-size: 0.75rem;
}
.stat-value {
font-size: 1.1rem;
}
.controls-right {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.controls-right > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.controls select {
min-width: auto;
flex: 1;
}
.refresh-button {
width: 100%;
justify-content: center;
}
.grid {
grid-template-columns: 1fr;
gap: 15px;
}
.graph-container {
height: 200px;
}
.list-container {
height: 180px;
}
.divider::before,
.divider::after {
width: 25%;
margin: 0 10px;
}
}
@media (max-width: 480px) {
.container {
padding: 10px;
}
h1 {
font-size: 1.3rem;
}
.controls-wrapper {
padding: 10px 12px;
}
.stats-summary {
gap: 8px;
}
.stat-item {
min-width: 70px;
padding: 5px 8px;
}
.stat-label {
font-size: 0.7rem;
}
.stat-value {
font-size: 1rem;
}
.controls select {
padding: 6px 8px;
font-size: 0.85rem;
padding-right: 25px;
}
.refresh-button {
padding: 6px 12px;
font-size: 0.85rem;
}
.card-header {
padding: 12px;
font-size: 0.9rem;
}
.card-body {
padding: 12px;
}
.graph-container {
height: 180px;
padding: 8px;
}
.list-container {
height: 160px;
}
.grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
/* Very small screens */
@media (max-width: 360px) {
.stats-summary {
gap: 6px;
}
.stat-item {
min-width: 60px;
padding: 4px 6px;
}
.stat-label {
font-size: 0.65rem;
}
.stat-value {
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Checkpoint Service Statistics</h1>
<div class="controls-wrapper">
<div class="controls">
<div class="controls-left">
<div class="stats-summary">
<div class="stat-item stat-hits">
<div class="stat-label">Total Hits</div>
<div class="stat-value" id="totalHits">--</div>
</div>
<div class="stat-item stat-passes">
<div class="stat-label">Total Passes</div>
<div class="stat-value" id="totalPasses">--</div>
</div>
<div class="stat-item stat-failures">
<div class="stat-label">Total Failures</div>
<div class="stat-value" id="totalFailures">--</div>
</div>
</div>
</div>
<div class="controls-right">
<div>
<label for="rangeSelect">Time Range:</label>
<select id="rangeSelect">
<option value="3600000">1 Hour</option>
<option value="21600000">6 Hours</option>
<option value="43200000">12 Hours</option>
<option value="86400000" selected>1 Day</option>
<option value="604800000">1 Week</option>
<option value="1209600000">2 Weeks</option>
<option value="2592000000">1 Month</option>
</select>
</div>
<button id="refreshBtn" class="refresh-button">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M23 4v6h-6" />
<path d="M1 20v-6h6" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
Refresh
</button>
</div>
</div>
</div>
<section class="section checkpoint-section">
<div class="grid">
<div class="card hits-card">
<div class="card-header">Checkpoint Hits</div>
<div class="card-body">
<div class="graph-container">
<canvas id="hitsChart"></canvas>
<div id="hitsLoading" class="loading" style="display: none">
<div class="spinner"></div>
<div class="loading-text">Loading data...</div>
</div>
</div>
</div>
</div>
<div class="card success-card">
<div class="card-header">Checkpoint Passes</div>
<div class="card-body">
<div class="graph-container">
<canvas id="successChart"></canvas>
<div id="successLoading" class="loading" style="display: none">
<div class="spinner"></div>
<div class="loading-text">Loading data...</div>
</div>
</div>
</div>
</div>
<div class="card failure-card">
<div class="card-header">Checkpoint Failures</div>
<div class="card-body">
<div class="graph-container">
<canvas id="failureChart"></canvas>
<div id="failureLoading" class="loading" style="display: none">
<div class="spinner"></div>
<div class="loading-text">Loading data...</div>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="divider">Plugin stats below this line</div>
<section class="section ipfilter-section">
<div class="section-header">
<h2 class="section-title">IPfilter Statistics</h2>
</div>
<div class="grid">
<div class="card">
<div class="card-header">Top Blocked ASNs</div>
<div class="card-body">
<div class="list-container" id="blockedAsns">
<div class="no-data">No data available yet</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Top Blocked IPs</div>
<div class="card-body">
<div class="list-container" id="blockedIps">
<div class="no-data">No data available yet</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Top Triggered Rules</div>
<div class="card-body">
<div class="list-container" id="triggeredRules">
<div class="no-data">No data available yet</div>
</div>
</div>
</div>
</div>
<p class="note">Data retention period: 30 days</p>
</section>
</div>
<script>
// First-visit detection
const isFirstVisit = sessionStorage.getItem('stats_visited') === null;
sessionStorage.setItem('stats_visited', 'true');
// Chart configuration
Chart.defaults.font.family =
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif";
Chart.defaults.font.size = 12;
Chart.defaults.plugins.legend.display = false;
Chart.defaults.elements.line.tension = 0.4;
Chart.defaults.elements.point.radius = 3;
Chart.defaults.elements.point.hoverRadius = 5;
// Adjust global animation duration and preserve easing
Chart.defaults.animation.duration = isFirstVisit ? 600 : 200;
Chart.defaults.animation.easing = 'easeOutQuart';
let currentRangeMs = 86400000; // 1 Day (default)
let chartInstances = {};
let isRefreshing = false;
const metrics = [
{
id: 'hitsChart',
metric: 'checkpoint.sent',
label: 'Checkpoint Hits',
color: 'rgba(67, 97, 238, 0.8)',
bgColor: 'rgba(67, 97, 238, 0.1)',
},
{
id: 'successChart',
metric: 'checkpoint.success',
label: 'Checkpoint Passes',
color: 'rgba(46, 204, 113, 0.8)',
bgColor: 'rgba(46, 204, 113, 0.1)',
},
{
id: 'failureChart',
metric: 'checkpoint.failure',
label: 'Checkpoint Failures',
color: 'rgba(231, 76, 60, 0.8)',
bgColor: 'rgba(231, 76, 60, 0.1)',
},
];
// Count-up animation for summary stats
function countUp(el, start, end, duration = 1000) {
// Skip animation if not first visit
if (!isFirstVisit) {
el.textContent = end;
return;
}
let startTime = null;
function step(time) {
if (!startTime) startTime = time;
const progress = Math.min((time - startTime) / duration, 1);
el.textContent = Math.floor(progress * (end - start) + start);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
// Handle time range changes
document.getElementById('rangeSelect').addEventListener('change', (e) => {
currentRangeMs = Number(e.target.value);
updateCharts();
updateIPFilterStats();
});
// Handle refresh button click
document.getElementById('refreshBtn').addEventListener('click', () => {
if (isRefreshing) return;
isRefreshing = true;
const btn = document.getElementById('refreshBtn');
const originalText = btn.innerHTML;
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Refreshing...`;
btn.disabled = true;
Promise.all([updateCharts(), updateIPFilterStats()]).finally(() => {
btn.innerHTML = originalText;
btn.disabled = false;
isRefreshing = false;
});
});
// Fetch data for charts
async function fetchData(metric) {
const id = metrics.find((m) => m.metric === metric)?.id;
if (id) showLoading(id, true);
try {
// Use a cache-busting technique to prevent stale data
const now = Date.now();
const start = now - currentRangeMs;
const cacheBuster = `&_=${now}`;
// Properly encode parameters to prevent injection
const url = `/stats/api?metric=${encodeURIComponent(
metric,
)}&start=${start}&end=${now}${cacheBuster}`;
const res = await fetch(url);
const data = res.ok ? await res.json() : [];
return data;
} catch (err) {
console.error(`Failed to fetch ${metric} data:`, err);
return [];
} finally {
if (id) showLoading(id, false);
}
}
// Show/hide loading indicators for charts
function showLoading(id, isLoading) {
const loadingEl = document.getElementById(`${id}Loading`);
if (loadingEl) {
loadingEl.style.display = isLoading ? 'flex' : 'none';
}
}
// Group data into time buckets
function bucketData(data, bucketSizeMs) {
// Memory optimization: ensure we process data efficiently for large datasets
const isLargeDataset = data.length > 5000;
const buckets = {};
// Faster processing for large datasets
if (isLargeDataset) {
// Use for loop for better performance with large arrays
for (let i = 0; i < data.length; i++) {
const bucket = Math.floor(data[i].timestamp / bucketSizeMs) * bucketSizeMs;
buckets[bucket] = (buckets[bucket] || 0) + 1;
}
} else {
// Use forEach for cleaner code with smaller datasets
data.forEach((item) => {
const bucket = Math.floor(item.timestamp / bucketSizeMs) * bucketSizeMs;
buckets[bucket] = (buckets[bucket] || 0) + 1;
});
}
return Object.entries(buckets)
.map(([time, count]) => ({ time: parseInt(time, 10), count }))
.sort((a, b) => a.time - b.time);
}
// Calculate and update summary metrics
function updateSummaryStats(metrics) {
if (!metrics || metrics.length === 0) return;
let hitPromise = fetchData('checkpoint.sent');
let passPromise = fetchData('checkpoint.success');
let failPromise = fetchData('checkpoint.failure');
Promise.all([hitPromise, passPromise, failPromise])
.then(([hits, passes, failures]) => {
countUp(document.getElementById('totalHits'), 0, hits.length);
countUp(document.getElementById('totalPasses'), 0, passes.length);
countUp(document.getElementById('totalFailures'), 0, failures.length);
})
.catch((err) => {
console.error('Failed to update summary stats:', err);
});
}
// Create or update charts
async function drawChart({ id, metric, label, color, bgColor }) {
// Set loading state
showLoading(id, true);
try {
const raw = await fetchData(metric);
// Dynamically adjust bucket count: 48 for <=1 day, 24 for <=1 week, 30 otherwise
let bucketCount;
if (currentRangeMs <= 86400000) {
bucketCount = 48;
} else if (currentRangeMs <= 604800000) {
bucketCount = 24;
} else {
bucketCount = 30;
}
// Performance: Use a smaller bucket size for large datasets
const dataSize = raw.length;
if (dataSize > 10000) {
bucketCount = Math.min(bucketCount, 12); // Reduce resolution for large datasets
}
const bucketSizeMs = Math.max(Math.floor(currentRangeMs / bucketCount), 60000); // min 1 minute
const series = bucketData(raw, bucketSizeMs);
const timestamps = series.map((pt) => pt.time);
const counts = series.map((pt) => pt.count);
// Create empty points for continuous line
if (timestamps.length > 1) {
const fullTimestamps = [];
const fullCounts = [];
const start = timestamps[0];
const end = timestamps[timestamps.length - 1];
for (let t = start; t <= end; t += bucketSizeMs) {
fullTimestamps.push(t);
const match = series.find((s) => s.time === t);
fullCounts.push(match ? match.count : 0);
}
// Use the full dataset with zero-filled gaps
if (fullTimestamps.length > timestamps.length) {
timestamps.length = 0;
counts.length = 0;
timestamps.push(...fullTimestamps);
counts.push(...fullCounts);
}
}
const labels = timestamps.map((t) => {
const date = new Date(t);
// Format based on range
if (currentRangeMs <= 86400000) {
// <= 1 day, show HH:MM
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (currentRangeMs <= 604800000) {
// <= 1 week, show Day HH:MM
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
} else {
// > 1 week, show MM/DD
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
}
});
const canvas = document.getElementById(id);
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Destroy existing chart instance if any
if (chartInstances[id]) {
chartInstances[id].destroy();
}
// Calculate max Y value for better scaling
const maxCount = Math.max(...counts, 1);
const yMax = maxCount <= 5 ? maxCount + 1 : undefined;
// Optimize point display based on data density
const pointRadius = counts.length > 30 ? (counts.length > 100 ? 0 : 1) : 3;
const pointHoverRadius = counts.length > 100 ? 3 : 5;
// Create new chart
chartInstances[id] = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label,
data: counts,
borderColor: color,
backgroundColor: bgColor || color.replace('0.8)', '0.1)'),
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: pointRadius,
pointHoverRadius: pointHoverRadius,
pointBackgroundColor: 'white',
pointBorderWidth: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
// Single animation configuration to avoid conflicts
animation: {
duration: counts.length > 500 ? 0 : isFirstVisit ? 800 : 200,
easing: 'easeOutQuart',
animateScale: true,
},
plugins: {
title: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
displayColors: false,
backgroundColor: 'rgba(0,0,0,0.8)',
bodyFont: {
size: 13,
},
padding: 10,
callbacks: {
title: (items) => {
return items[0].label;
},
label: (item) => {
return `${item.parsed.y} ${item.parsed.y === 1 ? 'event' : 'events'}`;
},
},
},
},
scales: {
x: {
display: true,
grid: {
display: false,
},
ticks: {
maxRotation: 45,
minRotation: 45,
// Limit x-axis ticks for performance with large datasets
maxTicksLimit: counts.length > 100 ? 6 : 12,
font: {
size: 10,
},
},
},
y: {
display: true,
beginAtZero: true,
suggestedMax: yMax,
title: {
display: false,
},
ticks: {
precision: 0,
font: {
size: 10,
},
},
grid: {
color: 'rgba(0,0,0,0.05)',
},
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
hover: {
mode: 'nearest',
intersect: false,
},
},
});
} catch (err) {
console.error(`Error drawing chart ${id}:`, err);
} finally {
// Always hide loading indicator when done
showLoading(id, false);
}
}
// Aggregate IPFilter data
async function updateIPFilterStats() {
const now = Date.now();
const start = now - currentRangeMs;
try {
const res = await fetch(`/stats/api?metric=ipfilter.block&start=${start}&end=${now}`);
if (!res.ok) return;
const data = await res.json();
if (!data || !data.length) return;
// Process ASNs
const asnCounts = {};
const ipCounts = {};
const ruleCounts = {};
data.forEach((item) => {
// Count ASNs
if (item.asn_org) {
asnCounts[item.asn_org] = (asnCounts[item.asn_org] || 0) + 1;
}
// Count IPs (using ip from data if available)
if (item.ip) {
ipCounts[item.ip] = (ipCounts[item.ip] || 0) + 1;
}
// Count rules (using type+value as rule identifier)
if (item.type && item.value) {
const rule = `${item.type}:${item.value}`;
ruleCounts[rule] = (ruleCounts[rule] || 0) + 1;
}
});
// Sort and display top ASNs
updateBlockList('blockedAsns', asnCounts);
// Sort and display top IPs
updateBlockList('blockedIps', ipCounts);
// Sort and display top rules
updateBlockList('triggeredRules', ruleCounts, (rule) => {
const [type, value] = rule.split(':');
return `${type.toUpperCase()}: ${value}`;
});
} catch (err) {
console.error('Failed to fetch IPfilter stats:', err);
}
}
function updateBlockList(elementId, countMap, labelFormatter = null) {
const container = document.getElementById(elementId);
if (!container) return;
const sortedItems = Object.entries(countMap)
.sort((a, b) => b[1] - a[1])
.slice(0, 10); // Top 10
if (sortedItems.length === 0) {
container.innerHTML = '<div class="no-data">No data available yet</div>';
return;
}
const html = sortedItems
.map(([key, count]) => {
const label = labelFormatter ? labelFormatter(key) : key;
return `
<div class="list-item">
<span>${label}</span>
<span class="list-item-count">${count}</span>
</div>
`;
})
.join('');
container.innerHTML = html;
}
async function updateCharts() {
// Update summary metrics
updateSummaryStats(metrics);
// Update all charts
const promises = [];
for (const cfg of metrics) {
promises.push(drawChart(cfg));
}
// Wait for all charts to finish rendering
await Promise.all(promises);
}
// Initialize on page load
window.addEventListener('DOMContentLoaded', () => {
// Apply animations on first visit only
if (isFirstVisit) {
// Animate summary stats
document.querySelectorAll('.stat-item').forEach((el, i) => {
el.classList.add('animate-fade-in', `delay-${i + 1}`);
});
// Animate charts
document.querySelectorAll('.card').forEach((el, i) => {
el.classList.add('animate-fade-in', `delay-${i + 2}`);
});
}
// Initially hide loading spinners
metrics.forEach((m) => {
showLoading(m.id, false);
});
// Then start loading data
updateCharts();
updateIPFilterStats();
// Refresh data periodically (every 5 minutes)
setInterval(() => {
updateCharts();
updateIPFilterStats();
}, 5 * 60 * 1000);
// Adjust for mobile screens
function handleResize() {
if (window.innerWidth < 600) {
Chart.defaults.font.size = 10;
Chart.defaults.elements.point.radius = 2;
metrics.forEach((m) => {
if (chartInstances[m.id]) {
chartInstances[m.id].options.scales.x.ticks.maxRotation = 90;
chartInstances[m.id].update();
}
});
} else {
Chart.defaults.font.size = 12;
Chart.defaults.elements.point.radius = 3;
metrics.forEach((m) => {
if (chartInstances[m.id]) {
chartInstances[m.id].options.scales.x.ticks.maxRotation = 45;
chartInstances[m.id].update();
}
});
}
}
// Initial resize check
handleResize();
// Listen for window resize
window.addEventListener('resize', handleResize);
});
</script>
</body>
</html>