Add Chart.js line/bar charts for attack trends (attempts over time, hourly pattern), an SVG world map choropleth colored by attack origin country, and a collapsible filter form (date range, IP, country, username) that narrows both charts and top-N tables. New store methods: GetAttemptsOverTime, GetHourlyPattern, GetCountryStats, and filtered variants of dashboard stats/top-N queries. New JSON API endpoints at /api/charts/* and an htmx fragment at /fragments/dashboard-content for filtered table updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
272 lines
9.5 KiB
JavaScript
272 lines
9.5 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
|
|
// Chart.js theme for Pico dark mode
|
|
Chart.defaults.color = '#b0b0b8';
|
|
Chart.defaults.borderColor = '#3a3a4a';
|
|
|
|
var attemptsChart = null;
|
|
var hourlyChart = null;
|
|
|
|
function getFilterParams() {
|
|
var form = document.getElementById('filter-form');
|
|
if (!form) return '';
|
|
var params = new URLSearchParams();
|
|
var since = form.elements['since'].value;
|
|
var until = form.elements['until'].value;
|
|
if (since) params.set('since', since);
|
|
if (until) params.set('until', until);
|
|
return params.toString();
|
|
}
|
|
|
|
function initAttemptsChart() {
|
|
var canvas = document.getElementById('chart-attempts');
|
|
if (!canvas) return;
|
|
var ctx = canvas.getContext('2d');
|
|
|
|
var qs = getFilterParams();
|
|
var url = '/api/charts/attempts-over-time' + (qs ? '?' + qs : '');
|
|
|
|
fetch(url)
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
var labels = data.points.map(function(p) { return p.date; });
|
|
var values = data.points.map(function(p) { return p.count; });
|
|
|
|
if (attemptsChart) {
|
|
attemptsChart.data.labels = labels;
|
|
attemptsChart.data.datasets[0].data = values;
|
|
attemptsChart.update();
|
|
return;
|
|
}
|
|
|
|
attemptsChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Attempts',
|
|
data: values,
|
|
borderColor: '#6366f1',
|
|
backgroundColor: 'rgba(99, 102, 241, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 2
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { display: false } },
|
|
y: { beginAtZero: true }
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function initHourlyChart() {
|
|
var canvas = document.getElementById('chart-hourly');
|
|
if (!canvas) return;
|
|
var ctx = canvas.getContext('2d');
|
|
|
|
var qs = getFilterParams();
|
|
var url = '/api/charts/hourly-pattern' + (qs ? '?' + qs : '');
|
|
|
|
fetch(url)
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
// Fill all 24 hours, defaulting to 0
|
|
var hourMap = {};
|
|
data.hours.forEach(function(h) { hourMap[h.hour] = h.count; });
|
|
var labels = [];
|
|
var values = [];
|
|
for (var i = 0; i < 24; i++) {
|
|
labels.push(i + ':00');
|
|
values.push(hourMap[i] || 0);
|
|
}
|
|
|
|
if (hourlyChart) {
|
|
hourlyChart.data.labels = labels;
|
|
hourlyChart.data.datasets[0].data = values;
|
|
hourlyChart.update();
|
|
return;
|
|
}
|
|
|
|
hourlyChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: 'Attempts',
|
|
data: values,
|
|
backgroundColor: 'rgba(99, 102, 241, 0.6)',
|
|
borderColor: '#6366f1',
|
|
borderWidth: 1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { grid: { display: false } },
|
|
y: { beginAtZero: true }
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function initWorldMap() {
|
|
var container = document.getElementById('world-map');
|
|
if (!container) return;
|
|
|
|
fetch('/static/world.svg')
|
|
.then(function(r) { return r.text(); })
|
|
.then(function(svgText) {
|
|
container.innerHTML = svgText;
|
|
|
|
fetch('/api/charts/country-stats')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
colorMap(container, data.countries);
|
|
});
|
|
});
|
|
}
|
|
|
|
function colorMap(container, countries) {
|
|
if (!countries || countries.length === 0) return;
|
|
|
|
var maxCount = countries[0].count; // already sorted DESC
|
|
var logMax = Math.log(maxCount + 1);
|
|
|
|
// Build lookup
|
|
var lookup = {};
|
|
countries.forEach(function(c) {
|
|
lookup[c.country.toLowerCase()] = c.count;
|
|
});
|
|
|
|
// Create tooltip element
|
|
var tooltip = document.createElement('div');
|
|
tooltip.id = 'map-tooltip';
|
|
tooltip.style.cssText = 'position:fixed;display:none;background:#1a1a2e;color:#e0e0e8;padding:4px 8px;border-radius:4px;font-size:13px;pointer-events:none;z-index:1000;border:1px solid #3a3a4a;';
|
|
document.body.appendChild(tooltip);
|
|
|
|
var svg = container.querySelector('svg');
|
|
if (!svg) return;
|
|
|
|
var paths = svg.querySelectorAll('path[id]');
|
|
paths.forEach(function(path) {
|
|
var id = path.id.toLowerCase();
|
|
if (id.charAt(0) === '_') return; // skip non-country paths
|
|
|
|
var count = lookup[id];
|
|
if (count) {
|
|
var intensity = Math.log(count + 1) / logMax;
|
|
var r = Math.round(30 + intensity * 69); // 30 -> 99
|
|
var g = Math.round(30 + intensity * 72); // 30 -> 102
|
|
var b = Math.round(62 + intensity * 179); // 62 -> 241
|
|
path.style.fill = 'rgb(' + r + ',' + g + ',' + b + ')';
|
|
}
|
|
|
|
path.addEventListener('mouseenter', function(e) {
|
|
var cc = id.toUpperCase();
|
|
var n = lookup[id] || 0;
|
|
tooltip.textContent = cc + ': ' + n.toLocaleString() + ' attempts';
|
|
tooltip.style.display = 'block';
|
|
});
|
|
|
|
path.addEventListener('mousemove', function(e) {
|
|
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
tooltip.style.top = (e.clientY - 10) + 'px';
|
|
});
|
|
|
|
path.addEventListener('mouseleave', function() {
|
|
tooltip.style.display = 'none';
|
|
});
|
|
|
|
path.addEventListener('click', function() {
|
|
var input = document.querySelector('#filter-form input[name="country"]');
|
|
if (input) {
|
|
input.value = id.toUpperCase();
|
|
applyFilters();
|
|
}
|
|
});
|
|
|
|
path.style.cursor = 'pointer';
|
|
});
|
|
}
|
|
|
|
function applyFilters() {
|
|
// Re-fetch charts with filter params
|
|
initAttemptsChart();
|
|
initHourlyChart();
|
|
|
|
// Re-fetch dashboard content via htmx
|
|
var form = document.getElementById('filter-form');
|
|
if (!form) return;
|
|
|
|
var params = new URLSearchParams();
|
|
['since', 'until', 'ip', 'country', 'username'].forEach(function(name) {
|
|
var val = form.elements[name].value;
|
|
if (val) params.set(name, val);
|
|
});
|
|
|
|
var target = document.getElementById('dashboard-content');
|
|
if (target) {
|
|
var url = '/fragments/dashboard-content?' + params.toString();
|
|
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
|
|
}
|
|
|
|
// Client-side filter for recent sessions table
|
|
filterSessionsTable(form);
|
|
}
|
|
|
|
function filterSessionsTable(form) {
|
|
var ip = form.elements['ip'].value.toLowerCase();
|
|
var country = form.elements['country'].value.toLowerCase();
|
|
var username = form.elements['username'].value.toLowerCase();
|
|
|
|
var rows = document.querySelectorAll('#recent-sessions-table tbody tr');
|
|
rows.forEach(function(row) {
|
|
var cells = row.querySelectorAll('td');
|
|
if (cells.length < 4) { row.style.display = ''; return; }
|
|
|
|
var show = true;
|
|
if (ip && cells[1].textContent.toLowerCase().indexOf(ip) === -1) show = false;
|
|
if (country && cells[2].textContent.toLowerCase().indexOf(country) === -1) show = false;
|
|
if (username && cells[3].textContent.toLowerCase().indexOf(username) === -1) show = false;
|
|
|
|
row.style.display = show ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
window.clearFilters = function() {
|
|
var form = document.getElementById('filter-form');
|
|
if (form) {
|
|
form.reset();
|
|
applyFilters();
|
|
}
|
|
};
|
|
|
|
window.applyFilters = applyFilters;
|
|
|
|
// Initialize on DOM ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initAttemptsChart();
|
|
initHourlyChart();
|
|
initWorldMap();
|
|
|
|
var form = document.getElementById('filter-form');
|
|
if (form) {
|
|
form.addEventListener('submit', function(e) {
|
|
e.preventDefault();
|
|
applyFilters();
|
|
});
|
|
}
|
|
});
|
|
})();
|