import Alpine from 'alpinejs'; import 'htmx.org'; import { Chart, BarController, BarElement, LineController, LineElement, PointElement, PieController, ArcElement, CategoryScale, LinearScale, Tooltip, Title, Legend, Filler, } from 'chart.js'; // Dark mode toggle. Layout's inline script handles the FOUC-free // initial paint; this just wires the toggle button. function applyTheme(theme) { if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } try { localStorage.setItem('irdb-theme', theme); } catch (e) { /* ignore */ } } document.addEventListener('click', (e) => { const target = e.target.closest('[data-theme-toggle]'); if (!target) return; const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark'; applyTheme(next); }); // htmx: send the per-session CSRF token on every state-changing request. document.body.addEventListener('htmx:configRequest', (e) => { const meta = document.querySelector('meta[name="csrf-token"]'); if (meta && meta.content) { e.detail.headers['X-CSRF-Token'] = meta.content; } }); // Dashboard charts. Each canvas carries its data in a data-attribute // (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the // pieces we use so the bundle stays small. Chart.register( BarController, BarElement, LineController, LineElement, PointElement, PieController, ArcElement, CategoryScale, LinearScale, Tooltip, Title, Legend, Filler, ); // Expose Chart globally so per-page inline scripts (e.g. the policy // score-distribution component) can render charts without re-bundling. window.Chart = Chart; // Palette tuned for both light and dark backgrounds. Reused across the // pie charts so categories/reporters don't share colours. const PIE_COLORS = [ '#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#14b8a6', ]; function chartTheme() { const isDark = document.documentElement.classList.contains('dark'); return { tickColor: isDark ? '#94a3b8' : '#475569', gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)', legendColor: isDark ? '#cbd5e1' : '#334155', }; } function parseSeries(canvas, attr) { try { return JSON.parse(canvas.dataset[attr] || '[]'); } catch (e) { return []; } } function renderReportsChart() { const canvas = document.getElementById('reports-chart'); if (!canvas) return; const buckets = parseSeries(canvas, 'buckets'); const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h')); const data = buckets.map((b) => b.count || 0); const t = chartTheme(); new Chart(canvas, { type: 'bar', data: { labels, datasets: [{ label: 'reports', data, backgroundColor: '#6366f1', }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } }, y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true }, }, }, }); } function renderPieChart(canvasId) { const canvas = document.getElementById(canvasId); if (!canvas) return; const series = parseSeries(canvas, 'series'); if (series.length === 0) return; const labelKey = canvas.dataset.labelKey || 'name'; const labels = series.map((row) => String(row[labelKey] ?? '')); const data = series.map((row) => row.count || 0); const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]); const t = chartTheme(); new Chart(canvas, { type: 'pie', data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1, }], }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } }, }, tooltip: { callbacks: { label: (ctx) => { const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1; const pct = ((ctx.parsed / total) * 100).toFixed(1); return `${ctx.label}: ${ctx.parsed} (${pct}%)`; }, }, }, }, }, }); } function renderBlockedIpsChart() { const canvas = document.getElementById('blocked-ips-chart'); if (!canvas) return; let payload; try { payload = JSON.parse(canvas.dataset.blocked || '{}'); } catch (e) { return; } const days = Array.isArray(payload.days) ? payload.days : []; const series = Array.isArray(payload.series) ? payload.series : []; if (days.length === 0 || series.length === 0) return; // Day-of-month for axis ticks; the ISO date is preserved in the // tooltip title via the closure on `days`. const labels = days.map((iso) => { const d = new Date(iso + 'T00:00:00Z'); return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }); const datasets = series.map((row, i) => { const colour = PIE_COLORS[i % PIE_COLORS.length]; return { label: row.category || '—', data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [], backgroundColor: colour, borderColor: colour, borderWidth: 0, }; }); const t = chartTheme(); new Chart(canvas, { type: 'bar', data: { labels, datasets }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } }, }, tooltip: { callbacks: { title: (items) => (items[0] && days[items[0].dataIndex]) || '', }, }, }, scales: { x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } }, y: { stacked: true, ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true, }, }, }, }); } document.addEventListener('DOMContentLoaded', () => { renderReportsChart(); renderPieChart('top-reporters-chart'); renderPieChart('top-categories-chart'); renderBlockedIpsChart(); }); // Locale-aware