app.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. import Alpine from 'alpinejs';
  2. import 'htmx.org';
  3. import {
  4. Chart,
  5. BarController,
  6. BarElement,
  7. LineController,
  8. LineElement,
  9. PointElement,
  10. PieController,
  11. ArcElement,
  12. CategoryScale,
  13. LinearScale,
  14. Tooltip,
  15. Title,
  16. Legend,
  17. Filler,
  18. } from 'chart.js';
  19. // Dark mode toggle. Layout's inline <head> script handles the FOUC-free
  20. // initial paint; this just wires the toggle button.
  21. function applyTheme(theme) {
  22. if (theme === 'dark') {
  23. document.documentElement.classList.add('dark');
  24. } else {
  25. document.documentElement.classList.remove('dark');
  26. }
  27. try {
  28. localStorage.setItem('irdb-theme', theme);
  29. } catch (e) {
  30. /* ignore */
  31. }
  32. }
  33. document.addEventListener('click', (e) => {
  34. const target = e.target.closest('[data-theme-toggle]');
  35. if (!target) return;
  36. const next = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
  37. applyTheme(next);
  38. });
  39. // htmx: send the per-session CSRF token on every state-changing request.
  40. document.body.addEventListener('htmx:configRequest', (e) => {
  41. const meta = document.querySelector('meta[name="csrf-token"]');
  42. if (meta && meta.content) {
  43. e.detail.headers['X-CSRF-Token'] = meta.content;
  44. }
  45. });
  46. // Dashboard charts. Each canvas carries its data in a data-attribute
  47. // (server-pre-bucketed; no AJAX). Chart.js is tree-shaken to just the
  48. // pieces we use so the bundle stays small.
  49. Chart.register(
  50. BarController,
  51. BarElement,
  52. LineController,
  53. LineElement,
  54. PointElement,
  55. PieController,
  56. ArcElement,
  57. CategoryScale,
  58. LinearScale,
  59. Tooltip,
  60. Title,
  61. Legend,
  62. Filler,
  63. );
  64. // Expose Chart globally so per-page inline scripts (e.g. the policy
  65. // score-distribution component) can render charts without re-bundling.
  66. window.Chart = Chart;
  67. // Faded "glass" palette: Tailwind -400 tones for a powdery, translucent
  68. // feel. Emerald-400 (#34d399) matches the mid-stop of the logo's gradient.
  69. const PIE_COLORS = [
  70. '#34d399', // emerald-400 (matches logo's mid-stop)
  71. '#22d3ee', // cyan-400
  72. '#818cf8', // indigo-400
  73. '#a78bfa', // violet-400
  74. '#f472b6', // pink-400
  75. '#fb7185', // rose-400
  76. '#fbbf24', // amber-400
  77. '#a3e635', // lime-400
  78. '#2dd4bf', // teal-400
  79. '#60a5fa', // blue-400
  80. ];
  81. function chartTheme() {
  82. const isDark = document.documentElement.classList.contains('dark');
  83. return {
  84. tickColor: isDark ? '#94a3b8' : '#475569',
  85. gridColor: isDark ? 'rgba(148,163,184,0.15)' : 'rgba(148,163,184,0.3)',
  86. legendColor: isDark ? '#cbd5e1' : '#334155',
  87. };
  88. }
  89. function hexToRgba(hex, alpha) {
  90. const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
  91. if (!m) return hex;
  92. return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${alpha})`;
  93. }
  94. function parseSeries(canvas, attr) {
  95. try {
  96. return JSON.parse(canvas.dataset[attr] || '[]');
  97. } catch (e) {
  98. return [];
  99. }
  100. }
  101. function renderReportsChart() {
  102. const canvas = document.getElementById('reports-chart');
  103. if (!canvas) return;
  104. const buckets = parseSeries(canvas, 'buckets');
  105. const labels = buckets.map((b) => (b.hour || '').replace(/.*T(\d{2}).*/, '$1h'));
  106. const data = buckets.map((b) => b.count || 0);
  107. const t = chartTheme();
  108. new Chart(canvas, {
  109. type: 'bar',
  110. data: {
  111. labels,
  112. datasets: [{
  113. label: 'reports',
  114. data,
  115. backgroundColor: 'rgba(52, 211, 153, 0.7)', // emerald-400 @ 70% — frosted bar
  116. borderColor: 'rgba(52, 211, 153, 0.9)',
  117. borderWidth: 1,
  118. borderRadius: 4,
  119. }],
  120. },
  121. options: {
  122. responsive: true,
  123. maintainAspectRatio: false,
  124. plugins: { legend: { display: false } },
  125. scales: {
  126. x: { ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  127. y: { ticks: { color: t.tickColor, precision: 0 }, grid: { color: t.gridColor }, beginAtZero: true },
  128. },
  129. },
  130. });
  131. }
  132. function renderPieChart(canvasId) {
  133. const canvas = document.getElementById(canvasId);
  134. if (!canvas) return;
  135. const series = parseSeries(canvas, 'series');
  136. if (series.length === 0) return;
  137. const labelKey = canvas.dataset.labelKey || 'name';
  138. const labels = series.map((row) => String(row[labelKey] ?? ''));
  139. const data = series.map((row) => row.count || 0);
  140. const colors = labels.map((_, i) => PIE_COLORS[i % PIE_COLORS.length]);
  141. const t = chartTheme();
  142. new Chart(canvas, {
  143. type: 'pie',
  144. data: {
  145. labels,
  146. datasets: [{
  147. data,
  148. backgroundColor: colors,
  149. borderColor: 'rgba(255,255,255,0.25)', // crisper glass-segment seam
  150. borderWidth: 1.5,
  151. }],
  152. },
  153. options: {
  154. responsive: true,
  155. maintainAspectRatio: false,
  156. plugins: {
  157. legend: {
  158. position: 'bottom',
  159. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  160. },
  161. tooltip: {
  162. callbacks: {
  163. label: (ctx) => {
  164. const total = ctx.dataset.data.reduce((acc, v) => acc + v, 0) || 1;
  165. const pct = ((ctx.parsed / total) * 100).toFixed(1);
  166. return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
  167. },
  168. },
  169. },
  170. },
  171. },
  172. });
  173. }
  174. function renderBlockedIpsChart() {
  175. const canvas = document.getElementById('blocked-ips-chart');
  176. if (!canvas) return;
  177. let payload;
  178. try {
  179. payload = JSON.parse(canvas.dataset.blocked || '{}');
  180. } catch (e) {
  181. return;
  182. }
  183. const days = Array.isArray(payload.days) ? payload.days : [];
  184. const series = Array.isArray(payload.series) ? payload.series : [];
  185. if (days.length === 0 || series.length === 0) return;
  186. // Day-of-month for axis ticks; the ISO date is preserved in the
  187. // tooltip title via the closure on `days`.
  188. const labels = days.map((iso) => {
  189. const d = new Date(iso + 'T00:00:00Z');
  190. return isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
  191. });
  192. const datasets = series.map((row, i) => {
  193. const colour = PIE_COLORS[i % PIE_COLORS.length];
  194. return {
  195. label: row.category || '—',
  196. data: Array.isArray(row.counts) ? row.counts.map((c) => c || 0) : [],
  197. backgroundColor: hexToRgba(colour, 0.7), // frosted fill
  198. borderColor: colour, // crisp glass edge
  199. borderWidth: 1,
  200. };
  201. });
  202. const t = chartTheme();
  203. new Chart(canvas, {
  204. type: 'bar',
  205. data: { labels, datasets },
  206. options: {
  207. responsive: true,
  208. maintainAspectRatio: false,
  209. plugins: {
  210. legend: {
  211. position: 'bottom',
  212. labels: { color: t.legendColor, boxWidth: 12, font: { size: 11 } },
  213. },
  214. tooltip: {
  215. callbacks: {
  216. title: (items) => (items[0] && days[items[0].dataIndex]) || '',
  217. },
  218. },
  219. },
  220. scales: {
  221. x: { stacked: true, ticks: { color: t.tickColor }, grid: { color: t.gridColor } },
  222. y: {
  223. stacked: true,
  224. ticks: { color: t.tickColor, precision: 0 },
  225. grid: { color: t.gridColor },
  226. beginAtZero: true,
  227. },
  228. },
  229. },
  230. });
  231. }
  232. document.addEventListener('DOMContentLoaded', () => {
  233. renderReportsChart();
  234. renderPieChart('top-reporters-chart');
  235. renderPieChart('top-categories-chart');
  236. renderBlockedIpsChart();
  237. });
  238. // Locale-aware <time> rendering. Templates emit `<time class="irdb-dt"
  239. // datetime="<iso>">…</iso></time>`; the text content holds the raw ISO
  240. // string as a no-JS fallback. This pass replaces it with the user's
  241. // browser locale formatting, with an optional configured fallback (set
  242. // via UI_LOCALE on the html data attribute) appended so browser locale
  243. // wins but a deployment can still ensure something sensible if the
  244. // browser's preference isn't supported. `data-irdb-dt-format="date"`
  245. // switches to date-only output.
  246. function getDateLocales() {
  247. const fallback = document.documentElement.getAttribute('data-irdb-locale-fallback');
  248. const locales = [];
  249. if (typeof navigator !== 'undefined' && navigator.language) {
  250. locales.push(navigator.language);
  251. }
  252. if (fallback && fallback.trim()) {
  253. locales.push(fallback.trim());
  254. }
  255. return locales.length > 0 ? locales : undefined;
  256. }
  257. function buildDateFormatters() {
  258. const locales = getDateLocales();
  259. return {
  260. datetime: new Intl.DateTimeFormat(locales, {
  261. year: 'numeric', month: '2-digit', day: '2-digit',
  262. hour: '2-digit', minute: '2-digit', second: '2-digit',
  263. }),
  264. date: new Intl.DateTimeFormat(locales, {
  265. year: 'numeric', month: '2-digit', day: '2-digit',
  266. }),
  267. };
  268. }
  269. function formatTimes(root) {
  270. const scope = root && root.querySelectorAll ? root : document;
  271. const formatters = buildDateFormatters();
  272. const elements = scope.querySelectorAll('time.irdb-dt[datetime]');
  273. elements.forEach((el) => {
  274. const iso = el.getAttribute('datetime');
  275. if (!iso) return;
  276. const d = new Date(iso);
  277. if (isNaN(d.getTime())) return;
  278. const fmt = el.dataset.irdbDtFormat === 'date' ? formatters.date : formatters.datetime;
  279. try {
  280. el.textContent = fmt.format(d);
  281. if (!el.hasAttribute('title')) {
  282. el.setAttribute('title', iso);
  283. }
  284. } catch (e) {
  285. /* leave the ISO fallback in place */
  286. }
  287. });
  288. }
  289. document.addEventListener('DOMContentLoaded', () => formatTimes(document));
  290. document.body.addEventListener('htmx:afterSettle', (e) => formatTimes(e.target));
  291. // Sortable tables. Tables marked with `data-sortable-table="<id>"` get
  292. // click-to-sort headers. Sort state lives in sessionStorage under
  293. // `irdb-sort:<id>` and is wiped on the login page so logging out
  294. // forgets it. The comparator is selected by the th's `data-sort-type`
  295. // (`string` | `number` | `date`); cells may override the displayed
  296. // text via `data-sort-value`.
  297. const SORT_KEY_PREFIX = 'irdb-sort:';
  298. function readSortState(tableId) {
  299. try {
  300. const raw = sessionStorage.getItem(SORT_KEY_PREFIX + tableId);
  301. if (!raw) return null;
  302. const parsed = JSON.parse(raw);
  303. if (parsed && typeof parsed.key === 'string' && (parsed.dir === 'asc' || parsed.dir === 'desc')) {
  304. return parsed;
  305. }
  306. } catch (e) { /* ignore */ }
  307. return null;
  308. }
  309. function writeSortState(tableId, state) {
  310. try {
  311. if (state) {
  312. sessionStorage.setItem(SORT_KEY_PREFIX + tableId, JSON.stringify(state));
  313. } else {
  314. sessionStorage.removeItem(SORT_KEY_PREFIX + tableId);
  315. }
  316. } catch (e) { /* ignore */ }
  317. }
  318. function clearAllSortState() {
  319. try {
  320. const keys = [];
  321. for (let i = 0; i < sessionStorage.length; i++) {
  322. const k = sessionStorage.key(i);
  323. if (k && k.startsWith(SORT_KEY_PREFIX)) keys.push(k);
  324. }
  325. keys.forEach((k) => sessionStorage.removeItem(k));
  326. } catch (e) { /* ignore */ }
  327. }
  328. function cellSortValue(cell, type) {
  329. if (!cell) return type === 'number' ? Number.NEGATIVE_INFINITY : '';
  330. const explicit = cell.getAttribute('data-sort-value');
  331. if (explicit !== null) return explicit;
  332. // <time datetime="..."> is the canonical date carrier.
  333. const timeEl = cell.querySelector('time[datetime]');
  334. if (timeEl) return timeEl.getAttribute('datetime') || '';
  335. return (cell.textContent || '').trim();
  336. }
  337. function compareValues(a, b, type) {
  338. if (type === 'number') {
  339. const na = parseFloat(a);
  340. const nb = parseFloat(b);
  341. const aNaN = Number.isNaN(na);
  342. const bNaN = Number.isNaN(nb);
  343. if (aNaN && bNaN) return 0;
  344. if (aNaN) return 1; // empty/— goes last on asc
  345. if (bNaN) return -1;
  346. return na - nb;
  347. }
  348. if (type === 'date') {
  349. const ta = a ? Date.parse(a) : NaN;
  350. const tb = b ? Date.parse(b) : NaN;
  351. const aNaN = Number.isNaN(ta);
  352. const bNaN = Number.isNaN(tb);
  353. if (aNaN && bNaN) return 0;
  354. if (aNaN) return 1;
  355. if (bNaN) return -1;
  356. return ta - tb;
  357. }
  358. // string
  359. return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true });
  360. }
  361. function findSortableThs(table) {
  362. // Only <th> in the *first* thead row (avoid scanning nested tables
  363. // — the score-chart has its own SVG layout, not a sortable table).
  364. const head = table.tHead;
  365. if (!head || !head.rows.length) return [];
  366. return Array.from(head.rows[0].cells).filter((c) => c.hasAttribute('data-sort-key'));
  367. }
  368. function applySort(table, key, dir) {
  369. const ths = findSortableThs(table);
  370. const th = ths.find((t) => t.getAttribute('data-sort-key') === key);
  371. if (!th) return;
  372. const colIndex = th.cellIndex;
  373. const type = th.getAttribute('data-sort-type') || 'string';
  374. const tbody = table.tBodies[0];
  375. if (!tbody) return;
  376. // Treat each contiguous run of rows with the same `data-sort-row-group`
  377. // attribute, or pairs marked with `data-sort-row-detail`, as a unit so
  378. // expandable rows (e.g. the audit log payload toggle) stay glued to
  379. // their parent. By default, every row sorts independently.
  380. const allRows = Array.from(tbody.rows);
  381. const groups = [];
  382. let current = null;
  383. for (const row of allRows) {
  384. if (row.hasAttribute('data-sort-row-detail') && current) {
  385. current.push(row);
  386. continue;
  387. }
  388. current = [row];
  389. groups.push(current);
  390. }
  391. groups.sort((ga, gb) => {
  392. const va = cellSortValue(ga[0].cells[colIndex], type);
  393. const vb = cellSortValue(gb[0].cells[colIndex], type);
  394. const cmp = compareValues(va, vb, type);
  395. return dir === 'desc' ? -cmp : cmp;
  396. });
  397. const frag = document.createDocumentFragment();
  398. groups.forEach((g) => g.forEach((r) => frag.appendChild(r)));
  399. tbody.appendChild(frag);
  400. // Update indicators.
  401. ths.forEach((t) => {
  402. const ind = t.querySelector('.sort-indicator');
  403. const isActive = t === th;
  404. if (ind) {
  405. ind.textContent = isActive ? (dir === 'asc' ? '▲' : '▼') : '↕';
  406. ind.classList.toggle('text-slate-300', !isActive);
  407. ind.classList.toggle('dark:text-slate-600', !isActive);
  408. ind.classList.toggle('text-indigo-600', isActive);
  409. ind.classList.toggle('dark:text-indigo-400', isActive);
  410. }
  411. t.setAttribute('aria-sort', isActive ? (dir === 'asc' ? 'ascending' : 'descending') : 'none');
  412. });
  413. }
  414. function initSortableTable(table) {
  415. if (!table || table.dataset.sortableInit === '1') return;
  416. table.dataset.sortableInit = '1';
  417. const tableId = table.getAttribute('data-sortable-table');
  418. if (!tableId) return;
  419. const ths = findSortableThs(table);
  420. if (ths.length === 0) return;
  421. ths.forEach((th) => {
  422. th.classList.add('cursor-pointer', 'select-none');
  423. th.addEventListener('click', () => {
  424. const key = th.getAttribute('data-sort-key');
  425. const current = readSortState(tableId);
  426. const dir = current && current.key === key && current.dir === 'asc' ? 'desc' : 'asc';
  427. writeSortState(tableId, { key, dir });
  428. applySort(table, key, dir);
  429. });
  430. });
  431. const initial = readSortState(tableId);
  432. if (initial) {
  433. applySort(table, initial.key, initial.dir);
  434. }
  435. }
  436. function initSortableTables(root) {
  437. const scope = root && root.querySelectorAll ? root : document;
  438. scope.querySelectorAll('table[data-sortable-table]').forEach(initSortableTable);
  439. }
  440. document.addEventListener('DOMContentLoaded', () => {
  441. // The login page is the canonical "logged out" landing spot —
  442. // wipe any sort state left over from the previous session.
  443. if (window.location && window.location.pathname === '/login') {
  444. clearAllSortState();
  445. }
  446. initSortableTables(document);
  447. });
  448. document.body.addEventListener('htmx:afterSettle', (e) => initSortableTables(e.target));
  449. window.Alpine = Alpine;
  450. Alpine.start();