iri-shield 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1682 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { randomBytes, timingSafeEqual } = require('crypto');
5
+
6
+ function createDashboardRouter({ storage, config }) {
7
+ const router = express.Router();
8
+ const auth = dashboardAuth(config.dashboard);
9
+
10
+ router.use(express.urlencoded({ extended: false }));
11
+ router.use(express.json({ limit: '50kb' }));
12
+
13
+ // --- Auth routes ---
14
+ router.get('/login', (req, res) => {
15
+ if (auth.isAuthenticated(req)) return res.redirect(req.baseUrl || '/');
16
+ res.type('html').send(renderLogin(config, false));
17
+ });
18
+
19
+ router.post('/login', (req, res) => {
20
+ if (auth.canLogin(req.body?.username, req.body?.password)) {
21
+ res.setHeader(
22
+ 'Set-Cookie',
23
+ `iri_shield_session=${auth.token}; HttpOnly; SameSite=Lax; Path=${config.dashboard.path || '/iri-shield'}; Max-Age=86400`
24
+ );
25
+ return res.redirect(req.baseUrl || '/');
26
+ }
27
+ return res.status(401).type('html').send(renderLogin(config, true));
28
+ });
29
+
30
+ router.post('/logout', (req, res) => {
31
+ res.setHeader(
32
+ 'Set-Cookie',
33
+ `iri_shield_session=; HttpOnly; SameSite=Lax; Path=${config.dashboard.path || '/iri-shield'}; Max-Age=0`
34
+ );
35
+ return res.redirect(`${req.baseUrl || ''}/login`);
36
+ });
37
+
38
+ // --- Auth guard ---
39
+ router.use((req, res, next) => {
40
+ if (auth.isAuthenticated(req)) return next();
41
+ if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Unauthorized' });
42
+ return res.redirect(`${req.baseUrl || ''}/login`);
43
+ });
44
+
45
+ // =========================================================================
46
+ // API endpoints
47
+ // =========================================================================
48
+
49
+ router.get('/api/stats', (req, res) => {
50
+ res.json({ ...storage.getStats(), config: publicConfig(config) });
51
+ });
52
+
53
+ // Paginated events
54
+ router.get('/api/events', (req, res) => {
55
+ const page = Math.max(1, parseInt(req.query.page) || 1);
56
+ const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
57
+ const riskLevel = req.query.riskLevel || null;
58
+ if (typeof storage.getEvents === 'function') {
59
+ res.json(storage.getEvents({ page, perPage, riskLevel }));
60
+ } else {
61
+ const events = (storage.events || []).filter((e) => !riskLevel || e.riskLevel === riskLevel);
62
+ res.json(paginateArray(events, page, perPage));
63
+ }
64
+ });
65
+
66
+ // Paginated clients
67
+ router.get('/api/clients', (req, res) => {
68
+ const page = Math.max(1, parseInt(req.query.page) || 1);
69
+ const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
70
+ if (typeof storage.getClients === 'function') {
71
+ res.json(storage.getClients({ page, perPage }));
72
+ } else {
73
+ const clients = Array.from((storage.clients || new Map()).values()).sort(
74
+ (a, b) => b.requestCount - a.requestCount
75
+ );
76
+ res.json(paginateArray(clients, page, perPage));
77
+ }
78
+ });
79
+
80
+ // Paginated blocked IPs with status filter
81
+ router.get('/api/blocked', (req, res) => {
82
+ const page = Math.max(1, parseInt(req.query.page) || 1);
83
+ const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
84
+ const status = req.query.status || 'all';
85
+ if (typeof storage.getBlockedIps === 'function') {
86
+ res.json(storage.getBlockedIps({ page, perPage, status }));
87
+ } else {
88
+ res.json(storage.getBlockedIps({ page, perPage, status }));
89
+ }
90
+ });
91
+
92
+ // Paginated alerts
93
+ router.get('/api/alerts', (req, res) => {
94
+ const page = Math.max(1, parseInt(req.query.page) || 1);
95
+ const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
96
+ const dismissed = req.query.dismissed === 'true';
97
+ if (typeof storage.getAlerts === 'function') {
98
+ res.json(storage.getAlerts({ page, perPage, dismissed }));
99
+ } else {
100
+ res.json(paginateArray([], page, perPage));
101
+ }
102
+ });
103
+
104
+ // Unblock / Delete Block for an IP
105
+ router.post('/api/unblock', (req, res) => {
106
+ const { ip } = req.body || {};
107
+ if (!ip) return res.status(400).json({ error: 'IP is required' });
108
+ if (typeof storage.unblockIp === 'function') storage.unblockIp(ip);
109
+ res.json({ ok: true, ip });
110
+ });
111
+
112
+ // Manual block / Re-block IP
113
+ router.post('/api/block', (req, res) => {
114
+ const { ip, reason, durationMs } = req.body || {};
115
+ if (!ip) return res.status(400).json({ error: 'IP is required' });
116
+ const duration = durationMs !== undefined ? Number(durationMs) : 24 * 60 * 60 * 1000;
117
+ if (typeof storage.manualBlockIp === 'function') {
118
+ storage.manualBlockIp(ip, { reason: reason || 'manual_block', durationMs: duration });
119
+ } else {
120
+ storage.blockIp(ip, {
121
+ expiresAt: duration > 0 ? Date.now() + duration : null,
122
+ reason: reason || 'manual_block',
123
+ score: 100,
124
+ manual: true,
125
+ blockedAt: new Date().toISOString()
126
+ });
127
+ }
128
+ res.json({ ok: true, ip, reason, durationMs: duration });
129
+ });
130
+
131
+ // Dismiss alert
132
+ router.post('/api/alerts/:clientId/dismiss', (req, res) => {
133
+ const { clientId } = req.params;
134
+ if (!clientId) return res.status(400).json({ error: 'clientId is required' });
135
+ const result = typeof storage.dismissAlert === 'function' ? storage.dismissAlert(clientId) : false;
136
+ res.json({ ok: true, clientId, dismissed: result });
137
+ });
138
+
139
+ // Settings GET
140
+ router.get('/api/settings', (req, res) => {
141
+ res.json(publicConfig(config));
142
+ });
143
+
144
+ // Settings POST
145
+ router.post('/api/settings', (req, res) => {
146
+ applyDashboardSettings(config, req.body || {});
147
+ res.json(publicConfig(config));
148
+ });
149
+
150
+ // --- Main dashboard page ---
151
+ router.get('/', (req, res) => {
152
+ res.type('html').send(renderDashboard(config, req.baseUrl || config.dashboard.path || '/iri-shield'));
153
+ });
154
+
155
+ return router;
156
+ }
157
+
158
+ // =============================================================================
159
+ // Dashboard HTML
160
+ // =============================================================================
161
+
162
+ function renderDashboard(config, baseUrl) {
163
+ const base = String(baseUrl || '/iri-shield').replace(/\/$/, '');
164
+ const refreshMs = Number(config.dashboard?.refreshMs || 300000);
165
+
166
+ return `<!doctype html>
167
+ <html lang="en">
168
+ <head>
169
+ <meta charset="utf-8" />
170
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
171
+ <title>iri-shield — Enterprise Security Dashboard</title>
172
+ <script src="https://cdn.tailwindcss.com"></script>
173
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
174
+ <style>
175
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
176
+ body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
177
+ .nav-item { transition: background 0.15s, color 0.15s; }
178
+ .nav-item.active { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
179
+ .nav-item.active .nav-icon { color: #1d4ed8; }
180
+ .badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; }
181
+ .badge-critical { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
182
+ .badge-high { background: #fff7ed; color: #c2410c; border: 1px solid #fed7aa; }
183
+ .badge-medium { background: #fefce8; color: #a16207; border: 1px solid #fef08a; }
184
+ .badge-low { background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0; }
185
+ .badge-none { background: #f8fafc; color: #64748b; border: 1px solid #e2e8f0; }
186
+ .badge-blue { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; }
187
+ .sidebar { width: 240px; min-width: 240px; }
188
+ .stat-card { transition: box-shadow 0.15s, transform 0.15s; }
189
+ .stat-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
190
+
191
+ /* Interactive mode card */
192
+ .mode-card {
193
+ border: 2px solid #e5e7eb;
194
+ border-radius: 12px;
195
+ padding: 16px;
196
+ cursor: pointer;
197
+ transition: all 0.2s ease;
198
+ position: relative;
199
+ }
200
+ .mode-card:hover { border-color: #93c5fd; background: #f8fafc; }
201
+ .mode-card.selected {
202
+ border-color: #2563eb;
203
+ background: #eff6ff;
204
+ box-shadow: 0 0 0 1px #2563eb;
205
+ }
206
+ .mode-card.selected .mode-radio-icon {
207
+ border-color: #2563eb;
208
+ background: #2563eb;
209
+ }
210
+
211
+ /* Buttons */
212
+ .btn-primary { background: #2563eb; color: #fff; border-radius: 8px; padding: 7px 16px; font-size: 14px; font-weight: 500; border: none; cursor: pointer; transition: background 0.15s; }
213
+ .btn-primary:hover { background: #1d4ed8; }
214
+ .btn-secondary { background: #fff; color: #374151; border-radius: 8px; padding: 7px 16px; font-size: 14px; font-weight: 500; border: 1px solid #e5e7eb; cursor: pointer; transition: background 0.15s; }
215
+ .btn-secondary:hover { background: #f9fafb; border-color: #d1d5db; }
216
+ .btn-danger { background: #dc2626; color: #fff; border-radius: 8px; padding: 7px 16px; font-size: 14px; font-weight: 500; border: none; cursor: pointer; transition: background 0.15s; }
217
+ .btn-danger:hover { background: #b91c1c; }
218
+ .btn-unblock { background: #fff; color: #059669; border: 1px solid #a7f3d0; border-radius: 6px; padding: 3px 10px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
219
+ .btn-unblock:hover { background: #ecfdf5; border-color: #34d399; }
220
+ .btn-dismiss { background: #fff; color: #6b7280; border: 1px solid #e5e7eb; border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
221
+ .btn-dismiss:hover { background: #f9fafb; color: #111827; }
222
+ .btn-block-small { background: #fff; color: #dc2626; border: 1px solid #fecaca; border-radius: 6px; padding: 3px 10px; font-size: 12px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
223
+ .btn-block-small:hover { background: #fef2f2; border-color: #f87171; }
224
+
225
+ /* Pagination */
226
+ .pagination { display: flex; gap: 6px; align-items: center; }
227
+ .page-btn { background: #fff; border: 1px solid #e5e7eb; border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; color: #374151; }
228
+ .page-btn:hover { background: #f3f4f6; border-color: #d1d5db; }
229
+ .page-btn.active { background: #2563eb; color: #fff; border-color: #2563eb; font-weight: 600; }
230
+
231
+ /* Tooltip / Hint indicator */
232
+ [title] { cursor: help; }
233
+ .event-row { cursor: pointer; transition: background 0.15s; }
234
+ .event-row:hover { background: #f1f5f9 !important; }
235
+ </style>
236
+ </head>
237
+ <body class="bg-gray-50 text-gray-900 min-h-screen">
238
+
239
+ <!-- Layout -->
240
+ <div class="flex min-h-screen">
241
+
242
+ <!-- Sidebar -->
243
+ <aside class="sidebar bg-white border-r border-gray-200 flex flex-col shrink-0">
244
+ <!-- Brand -->
245
+ <div class="px-5 py-5 border-b border-gray-100">
246
+ <div class="flex items-center gap-2.5">
247
+ <div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shrink-0 shadow-sm">
248
+ <svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
249
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
250
+ </svg>
251
+ </div>
252
+ <div>
253
+ <p class="text-xs text-gray-400 font-medium uppercase tracking-wider">Security</p>
254
+ <h1 class="text-base font-bold text-gray-900 leading-none mt-0.5">iri-shield</h1>
255
+ </div>
256
+ </div>
257
+ <div class="mt-3 flex items-center gap-2">
258
+ <span id="sidebarMode" class="badge badge-blue text-xs" title="Current security protection mode">Mode: ${escapeHtml(config.security || 'medium')}</span>
259
+ <span class="text-xs text-emerald-600 font-medium flex items-center gap-1">
260
+ <span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span> Active
261
+ </span>
262
+ </div>
263
+ </div>
264
+
265
+ <!-- Nav -->
266
+ <nav class="flex-1 px-3 py-4 space-y-0.5">
267
+ ${navItem('overview', 'Overview', iconOverview())}
268
+ ${navItem('alerts', 'Alerts', iconAlerts(), 'alertBadge')}
269
+ ${navItem('events', 'Security Events', iconEvents())}
270
+ ${navItem('clients', 'Clients', iconClients())}
271
+ ${navItem('blocked', 'Blocked IPs', iconBlocked(), 'blockedBadge')}
272
+ ${navItem('settings', 'Settings', iconSettings())}
273
+ </nav>
274
+
275
+ <!-- Footer info -->
276
+ <div class="px-4 py-4 border-t border-gray-100 space-y-2">
277
+ <div class="flex items-center justify-between text-xs text-gray-400">
278
+ <span>Auto-refresh</span>
279
+ <span id="refreshLabel" class="font-semibold text-gray-600">${Math.round(refreshMs / 1000)}s</span>
280
+ </div>
281
+ <div class="text-xs text-gray-500 font-medium" id="updatedAt">Updating…</div>
282
+ <form method="post" action="${escapeHtml(base)}/logout">
283
+ <button class="w-full mt-1 rounded-md border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50 hover:border-gray-300 transition-colors" type="submit">
284
+ Sign out
285
+ </button>
286
+ </form>
287
+ </div>
288
+ </aside>
289
+
290
+ <!-- Main content -->
291
+ <main class="flex-1 overflow-auto">
292
+
293
+ <!-- Page header -->
294
+ <div class="bg-white border-b border-gray-200 px-8 py-4 sticky top-0 z-10 shadow-xs">
295
+ <div class="flex items-center justify-between">
296
+ <div>
297
+ <h2 class="text-lg font-semibold text-gray-900" id="pageTitle">Overview</h2>
298
+ <p class="text-xs text-gray-400 mt-0.5" id="pageSubtitle">Real-time API traffic and threat monitoring</p>
299
+ </div>
300
+ <div class="flex items-center gap-3">
301
+ <span class="text-xs font-mono text-gray-600 bg-gray-100 px-2.5 py-1 rounded font-medium" id="appName">${escapeHtml(config.appName || 'iri-shield')}</span>
302
+ <div class="w-px h-4 bg-gray-200"></div>
303
+ <span class="text-xs text-gray-600 font-medium" id="storageMode">Storage: ${escapeHtml(config.storage?.mode || 'sqlite')}</span>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <div class="px-8 py-6">
309
+
310
+ <!-- ===== OVERVIEW PANEL ===== -->
311
+ <section class="panel" id="panel-overview">
312
+ <!-- Stat cards -->
313
+ <div class="grid grid-cols-2 xl:grid-cols-4 gap-4">
314
+ ${statCard('totalRequests', 'Total Requests', iconReq(), 'text-gray-900', 'bg-gray-50')}
315
+ ${statCard('detectedThreats', 'Threats Detected', iconThreat(), 'text-amber-700', 'bg-amber-50')}
316
+ ${statCard('blockedCombined', 'Active Blocked', iconBlock(), 'text-red-700', 'bg-red-50')}
317
+ ${statCard('activeAlerts', 'Active Alerts', iconAlert(), 'text-blue-700', 'bg-blue-50')}
318
+ </div>
319
+
320
+ <!-- Charts row -->
321
+ <div class="mt-6 grid grid-cols-1 xl:grid-cols-2 gap-6">
322
+ <div class="bg-white rounded-xl border border-gray-200 p-5 shadow-xs">
323
+ <div class="flex items-center justify-between mb-4">
324
+ <div>
325
+ <h3 class="font-semibold text-gray-900">Threat Distribution</h3>
326
+ <p class="text-xs text-gray-400">Categorized security threats detected</p>
327
+ </div>
328
+ <span class="text-xs text-gray-400 font-medium" id="redactionsLabel">0 redactions</span>
329
+ </div>
330
+ <div class="relative" style="height: 220px;">
331
+ <canvas id="threatChart"></canvas>
332
+ </div>
333
+ </div>
334
+ <div class="bg-white rounded-xl border border-gray-200 p-5 shadow-xs">
335
+ <div class="flex items-center justify-between mb-4">
336
+ <div>
337
+ <h3 class="font-semibold text-gray-900">Top Endpoints</h3>
338
+ <p class="text-xs text-gray-400">Most active API paths and latency</p>
339
+ </div>
340
+ </div>
341
+ <div class="overflow-x-auto max-h-[220px]">
342
+ <table class="w-full text-sm">
343
+ <thead><tr class="text-xs text-gray-400 uppercase tracking-wide border-b border-gray-100">
344
+ <th class="pb-2 text-left font-medium">Endpoint</th>
345
+ <th class="pb-2 text-right font-medium">Hits</th>
346
+ <th class="pb-2 text-right font-medium">Errors</th>
347
+ <th class="pb-2 text-right font-medium">Avg Latency</th>
348
+ </tr></thead>
349
+ <tbody id="endpointRows" class="divide-y divide-gray-50">
350
+ <tr><td class="py-3 text-gray-400 text-sm" colspan="4">Loading…</td></tr>
351
+ </tbody>
352
+ </table>
353
+ </div>
354
+ </div>
355
+ </div>
356
+
357
+ <!-- Quick stats row -->
358
+ <div class="mt-6 grid grid-cols-2 xl:grid-cols-4 gap-4">
359
+ ${miniStat('avgLatency', 'Avg Latency', 'ms')}
360
+ ${miniStat('anomalyEvents', 'Anomaly Events', '')}
361
+ ${miniStat('totalRedactions', 'PII Redactions', '')}
362
+ ${miniStat('blockedIpsCount', 'Active Blocked IPs', '')}
363
+ </div>
364
+ </section>
365
+
366
+ <!-- ===== ALERTS PANEL ===== -->
367
+ <section class="panel hidden" id="panel-alerts">
368
+ <div class="flex items-center justify-between mb-5">
369
+ <div>
370
+ <h3 class="font-semibold text-gray-900 text-base">Suspicious Client Alerts</h3>
371
+ <p class="text-sm text-gray-500 mt-0.5">Clients with elevated risk scores under observation (not yet automatically blocked)</p>
372
+ </div>
373
+ <button onclick="loadAlerts(currentPage.alerts)" class="btn-secondary text-sm flex items-center gap-1.5">
374
+ <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
375
+ Refresh Alerts
376
+ </button>
377
+ </div>
378
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
379
+ <table class="w-full text-sm">
380
+ <thead><tr class="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-200 bg-gray-50">
381
+ <th class="px-4 py-3 text-left font-medium">Client ID</th>
382
+ <th class="px-4 py-3 text-left font-medium">Last IP</th>
383
+ <th class="px-4 py-3 text-left font-medium">Risk</th>
384
+ <th class="px-4 py-3 text-right font-medium">Score</th>
385
+ <th class="px-4 py-3 text-left font-medium">Threats Detected</th>
386
+ <th class="px-4 py-3 text-left font-medium">Last Alert</th>
387
+ <th class="px-4 py-3 text-right font-medium">Alerts</th>
388
+ <th class="px-4 py-3 text-left font-medium">Actions</th>
389
+ </tr></thead>
390
+ <tbody id="alertRows" class="divide-y divide-gray-100">
391
+ <tr><td class="px-4 py-4 text-gray-400" colspan="8">Loading…</td></tr>
392
+ </tbody>
393
+ </table>
394
+ </div>
395
+ <div class="mt-4" id="alertPagination"></div>
396
+ </section>
397
+
398
+ <!-- ===== EVENTS PANEL ===== -->
399
+ <section class="panel hidden" id="panel-events">
400
+ <div class="flex items-center gap-3 mb-5 flex-wrap">
401
+ <div>
402
+ <h3 class="font-semibold text-gray-900 text-base">Security Events Log</h3>
403
+ <p class="text-xs text-gray-500 mt-0.5">Click on any event row to expand complete details (User-Agent, Reasons, Client context)</p>
404
+ </div>
405
+ <div class="flex items-center gap-2 ml-auto">
406
+ <select id="eventRiskFilter" onchange="loadEvents(1)" class="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
407
+ <option value="">All Risk Levels</option>
408
+ <option value="critical">Critical</option>
409
+ <option value="high">High</option>
410
+ <option value="medium">Medium</option>
411
+ <option value="low">Low</option>
412
+ </select>
413
+ <button onclick="loadEvents(currentPage.events)" class="btn-secondary text-sm flex items-center gap-1.5">
414
+ <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
415
+ Refresh
416
+ </button>
417
+ </div>
418
+ </div>
419
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
420
+ <table class="w-full text-sm">
421
+ <thead><tr class="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-200 bg-gray-50">
422
+ <th class="px-4 py-3 text-left font-medium w-6"></th>
423
+ <th class="px-4 py-3 text-left font-medium">Timestamp</th>
424
+ <th class="px-4 py-3 text-left font-medium">IP Address</th>
425
+ <th class="px-4 py-3 text-left font-medium">Endpoint</th>
426
+ <th class="px-4 py-3 text-left font-medium">Threat Category</th>
427
+ <th class="px-4 py-3 text-left font-medium">Risk-Confidence</th>
428
+ <th class="px-4 py-3 text-right font-medium">Score</th>
429
+ <th class="px-4 py-3 text-left font-medium">Mitigation</th>
430
+ </tr></thead>
431
+ <tbody id="eventRows" class="divide-y divide-gray-100">
432
+ <tr><td class="px-4 py-4 text-gray-400" colspan="8">Loading…</td></tr>
433
+ </tbody>
434
+ </table>
435
+ </div>
436
+ <div class="mt-4" id="eventPagination"></div>
437
+ </section>
438
+
439
+ <!-- ===== CLIENTS PANEL ===== -->
440
+ <section class="panel hidden" id="panel-clients">
441
+ <div class="flex items-center justify-between mb-5">
442
+ <div>
443
+ <h3 class="font-semibold text-gray-900 text-base">Client Identity Monitoring</h3>
444
+ <p class="text-sm text-gray-500 mt-0.5">Unique clients fingerprinted across sessions, IPs, User-Agents and devices</p>
445
+ </div>
446
+ <button onclick="loadClients(currentPage.clients)" class="btn-secondary text-sm flex items-center gap-1.5">
447
+ <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
448
+ Refresh
449
+ </button>
450
+ </div>
451
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
452
+ <table class="w-full text-sm">
453
+ <thead><tr class="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-200 bg-gray-50">
454
+ <th class="px-4 py-3 text-left font-medium">Client ID</th>
455
+ <th class="px-4 py-3 text-left font-medium">User ID</th>
456
+ <th class="px-4 py-3 text-left font-medium">Last Known IP</th>
457
+ <th class="px-4 py-3 text-right font-medium">Total Requests</th>
458
+ <th class="px-4 py-3 text-right font-medium">IPs Used</th>
459
+ <th class="px-4 py-3 text-right font-medium">Identity Drift</th>
460
+ <th class="px-4 py-3 text-right font-medium">Fingerprints</th>
461
+ <th class="px-4 py-3 text-left font-medium">Risk Status</th>
462
+ <th class="px-4 py-3 text-left font-medium">Last Seen</th>
463
+ </tr></thead>
464
+ <tbody id="clientRows" class="divide-y divide-gray-100">
465
+ <tr><td class="px-4 py-4 text-gray-400" colspan="9">Loading…</td></tr>
466
+ </tbody>
467
+ </table>
468
+ </div>
469
+ <div class="mt-4" id="clientPagination"></div>
470
+ </section>
471
+
472
+ <!-- ===== BLOCKED IPs PANEL ===== -->
473
+ <section class="panel hidden" id="panel-blocked">
474
+ <div class="flex items-center justify-between mb-5 flex-wrap gap-3">
475
+ <div>
476
+ <h3 class="font-semibold text-gray-900 text-base">Blocked IP Management</h3>
477
+ <p class="text-sm text-gray-500 mt-0.5">Manage active and historical IP restrictions</p>
478
+ </div>
479
+ <div class="flex items-center gap-2">
480
+ <select id="blockedStatusFilter" onchange="loadBlocked(1)" class="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500">
481
+ <option value="all">All Blocks (Active & History)</option>
482
+ <option value="active">Active Blocks Only</option>
483
+ <option value="expired">Expired Blocks</option>
484
+ </select>
485
+ <button onclick="loadBlocked(currentPage.blocked)" class="btn-secondary text-sm flex items-center gap-1.5">
486
+ <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
487
+ Refresh
488
+ </button>
489
+ <button onclick="showBlockModal()" class="btn-primary text-sm flex items-center gap-1.5">
490
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
491
+ Block IP Address
492
+ </button>
493
+ </div>
494
+ </div>
495
+ <div class="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-xs">
496
+ <table class="w-full text-sm">
497
+ <thead><tr class="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-200 bg-gray-50">
498
+ <th class="px-4 py-3 text-left font-medium">IP Address</th>
499
+ <th class="px-4 py-3 text-left font-medium">Block Reason</th>
500
+ <th class="px-4 py-3 text-right font-medium">Risk Score</th>
501
+ <th class="px-4 py-3 text-left font-medium">Type</th>
502
+ <th class="px-4 py-3 text-left font-medium">Status</th>
503
+ <th class="px-4 py-3 text-left font-medium">Blocked At</th>
504
+ <th class="px-4 py-3 text-left font-medium">Expires At</th>
505
+ <th class="px-4 py-3 text-left font-medium">Actions</th>
506
+ </tr></thead>
507
+ <tbody id="blockedRows" class="divide-y divide-gray-100">
508
+ <tr><td class="px-4 py-4 text-gray-400" colspan="8">Loading…</td></tr>
509
+ </tbody>
510
+ </table>
511
+ </div>
512
+ <div class="mt-4" id="blockedPagination"></div>
513
+ </section>
514
+
515
+ <!-- ===== SETTINGS PANEL ===== -->
516
+ <section class="panel hidden" id="panel-settings">
517
+ <form id="settingsForm">
518
+ <!-- Security Mode Selector -->
519
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
520
+ <div class="mb-4">
521
+ <h3 class="font-semibold text-gray-900 text-base">Security Mode</h3>
522
+ <p class="text-xs text-gray-500 mt-0.5">Select protection preset. Mode applies immediately upon saving.</p>
523
+ </div>
524
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4" id="modeCardsContainer">
525
+ ${modeCard('low', 'Low Mode', 'Relaxed protection. 300 req/min, auto-block score threshold 90.', config.security === 'low')}
526
+ ${modeCard('medium', 'Medium Mode', 'Balanced enterprise protection. 120 req/min, block threshold 80.', (config.security || 'medium') === 'medium')}
527
+ ${modeCard('high', 'High Mode', 'Strict protection. 30 req/min, block threshold 60, 1.3x threat multipliers.', config.security === 'high')}
528
+ </div>
529
+ </div>
530
+
531
+ <!-- Rate Limiting -->
532
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
533
+ <h3 class="font-semibold text-gray-900 text-base mb-4">Rate Limiting Parameters</h3>
534
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
535
+ ${numInput('Max requests per window', 'rateLimit.max', config.rateLimit.max)}
536
+ ${numInput('Window duration (ms)', 'rateLimit.windowMs', config.rateLimit.windowMs)}
537
+ ${checkInput('Enable rate limiting', 'rateLimit.enabled', config.rateLimit.enabled)}
538
+ </div>
539
+ </div>
540
+
541
+ <!-- Block Settings -->
542
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
543
+ <h3 class="font-semibold text-gray-900 text-base mb-4">Automated IP Blocking</h3>
544
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
545
+ ${numInput('Auto-block threshold score (0-100)', 'block.threshold', config.block.threshold)}
546
+ ${numInput('Block duration (ms)', 'block.durationMs', config.block.durationMs)}
547
+ ${checkInput('Enable automated IP blocking', 'block.enabled', config.block.enabled)}
548
+ </div>
549
+ </div>
550
+
551
+ <!-- Anomaly Thresholds -->
552
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
553
+ <h3 class="font-semibold text-gray-900 text-base mb-4">Anomaly Detection Thresholds</h3>
554
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
555
+ ${numInput('Medium risk threshold', 'anomaly.mediumThreshold', config.anomaly.mediumThreshold)}
556
+ ${numInput('High risk threshold', 'anomaly.highThreshold', config.anomaly.highThreshold)}
557
+ ${numInput('Critical risk threshold', 'anomaly.criticalThreshold', config.anomaly.criticalThreshold)}
558
+ ${numInput('Single endpoint flood max hits', 'anomaly.singleEndpointMax', config.anomaly.singleEndpointMax)}
559
+ ${numInput('Failed auth attempts limit', 'anomaly.failedAuthMax', config.anomaly.failedAuthMax)}
560
+ ${numInput('Alert threshold score', 'alert.threshold', config.alert?.threshold || 35)}
561
+ </div>
562
+ </div>
563
+
564
+ <!-- Security Rule Engine -->
565
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
566
+ <div class="flex items-center justify-between mb-4">
567
+ <div>
568
+ <h3 class="font-semibold text-gray-900 text-base">Security Rule Engine</h3>
569
+ <p class="text-xs text-gray-500 mt-0.5">Toggle individual threat detection modules or customize detection capabilities.</p>
570
+ </div>
571
+ <span class="badge badge-blue">Rule Engine Active</span>
572
+ </div>
573
+ <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 mb-2">
574
+ ${checkInput('SQL Injection', 'rules.sqlInjection', config.rules?.sqlInjection !== false)}
575
+ ${checkInput('XSS Attacks', 'rules.xss', config.rules?.xss !== false)}
576
+ ${checkInput('Path Traversal', 'rules.pathTraversal', config.rules?.pathTraversal !== false)}
577
+ ${checkInput('Command Injection', 'rules.commandInjection', config.rules?.commandInjection !== false)}
578
+ ${checkInput('SSTI Template Inj.', 'rules.ssti', config.rules?.ssti !== false)}
579
+ ${checkInput('NoSQL Injection', 'rules.nosqlInjection', config.rules?.nosqlInjection !== false)}
580
+ ${checkInput('Secret / Env Probe', 'rules.secretProbe', config.rules?.secretProbe !== false)}
581
+ ${checkInput('Scanner & Bot Detect', 'rules.scannerDetection', config.rules?.scannerDetection !== false)}
582
+ ${checkInput('Open Redirects', 'rules.openRedirect', config.rules?.openRedirect !== false)}
583
+ ${checkInput('XXE / XML Injection', 'rules.xxe', config.rules?.xxe !== false)}
584
+ ${checkInput('Header Anomaly', 'rules.headerAnomaly', config.rules?.headerAnomaly !== false)}
585
+ ${checkInput('Base64 Suspicious', 'rules.base64Payload', config.rules?.base64Payload !== false)}
586
+ </div>
587
+ </div>
588
+
589
+ <!-- Privacy & Failure Policy -->
590
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
591
+ <h3 class="font-semibold text-gray-900 text-base mb-4">Privacy by Design &amp; Failure Mode</h3>
592
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
593
+ ${checkInput('Hash IP addresses in storage', 'privacy.hashIp', config.privacy?.hashIp || false)}
594
+ ${numInput('Log retention period (days)', 'privacy.retentionDays', config.privacy?.retentionDays || 30)}
595
+ <div>
596
+ <label class="block text-sm font-medium text-gray-700 mb-1.5">Failure Mode Policy</label>
597
+ <select name="failureMode" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white">
598
+ <option value="fail-open" ${config.failureMode !== 'fail-closed' ? 'selected' : ''}>Fail-Open (Default — API traffic continues)</option>
599
+ <option value="fail-closed" ${config.failureMode === 'fail-closed' ? 'selected' : ''}>Fail-Closed (Strict — rejects on failure)</option>
600
+ </select>
601
+ </div>
602
+ </div>
603
+ </div>
604
+
605
+ <!-- System & Redaction -->
606
+ <div class="bg-white rounded-xl border border-gray-200 p-6 mb-5 shadow-xs">
607
+ <h3 class="font-semibold text-gray-900 text-base mb-4">System, Helmet &amp; Redaction</h3>
608
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
609
+ ${numInput('Dashboard auto-refresh (ms)', 'dashboard.refreshMs', config.dashboard?.refreshMs || 300000)}
610
+ ${checkInput('Enable response redaction', 'redaction.enabled', config.redaction?.enabled !== false)}
611
+ ${checkInput('Enable Helmet security headers', 'helmet.enabled', config.helmet?.enabled !== false)}
612
+ ${checkInput('Testing mode enabled', 'testing.enabled', config.testing?.enabled || false)}
613
+ ${checkInput('Allow client test overrides', 'testing.allowClientOverrides', config.testing?.allowClientOverrides || false)}
614
+ ${checkInput('Enable active alerts queue', 'alert.enabled', config.alert?.enabled !== false)}
615
+ </div>
616
+ <div>
617
+ <label class="block text-sm font-medium text-gray-700 mb-1.5">Sensitive Redaction Fields (comma-separated)</label>
618
+ <textarea class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none" rows="2" name="redaction.fields">${escapeHtml((config.redaction?.fields || []).join(', '))}</textarea>
619
+ </div>
620
+ </div>
621
+
622
+ <div class="flex items-center gap-4">
623
+ <button class="btn-primary" type="submit">Save All Settings</button>
624
+ <span class="text-sm text-emerald-600 font-medium" id="settingsMsg"></span>
625
+ </div>
626
+ </form>
627
+ </section>
628
+
629
+ </div><!-- /px-8 py-6 -->
630
+ </main>
631
+ </div>
632
+
633
+ <!-- Block IP Modal -->
634
+ <div id="blockModal" class="fixed inset-0 bg-black/40 z-50 hidden flex items-center justify-center p-4">
635
+ <div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6">
636
+ <h3 class="text-lg font-semibold text-gray-900 mb-1">Manually Block IP Address</h3>
637
+ <p class="text-xs text-gray-500 mb-4">Add an IP address to the persistent blocklist.</p>
638
+ <div class="space-y-3">
639
+ <div>
640
+ <label class="block text-sm font-medium text-gray-700 mb-1">IP Address</label>
641
+ <input id="blockIpInput" type="text" placeholder="e.g. 203.0.113.77" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono" />
642
+ </div>
643
+ <div>
644
+ <label class="block text-sm font-medium text-gray-700 mb-1">Block Reason</label>
645
+ <input id="blockReasonInput" type="text" placeholder="manual_admin_block" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
646
+ </div>
647
+ <div>
648
+ <label class="block text-sm font-medium text-gray-700 mb-1">Block Duration</label>
649
+ <select id="blockDurationInput" class="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
650
+ <option value="86400000" selected>24 hours</option>
651
+ <option value="3600000">1 hour</option>
652
+ <option value="21600000">6 hours</option>
653
+ <option value="604800000">7 days</option>
654
+ <option value="0">Permanent Block</option>
655
+ </select>
656
+ </div>
657
+ </div>
658
+ <div class="mt-5 flex gap-3">
659
+ <button onclick="submitBlock()" class="btn-danger flex-1">Apply Block</button>
660
+ <button onclick="hideBlockModal()" class="btn-secondary flex-1">Cancel</button>
661
+ </div>
662
+ </div>
663
+ </div>
664
+
665
+ <script>
666
+ const BASE = '${escapeJsString(base)}';
667
+ const fmt = new Intl.NumberFormat();
668
+ let refreshMs = ${refreshMs};
669
+ let timer;
670
+ let chart;
671
+ let rawEventsData = [];
672
+ let activeBlockedIps = new Set();
673
+ let currentPage = { events: 1, clients: 1, blocked: 1, alerts: 1 };
674
+
675
+ // Friendly shortened labels mapping for threat chart
676
+ const THREAT_SHORT_LABELS = {
677
+ 'sql_injection': 'SQL Inj',
678
+ 'sql_injection_pattern': 'SQL Inj',
679
+ 'xss_pattern': 'XSS',
680
+ 'path_traversal': 'Path Trav',
681
+ 'path_traversal_pattern': 'Path Trav',
682
+ 'command_injection': 'Cmd Inj',
683
+ 'ssti_pattern': 'SSTI',
684
+ 'nosql_injection': 'NoSQL Inj',
685
+ 'secret_probe': 'Secret Probe',
686
+ 'xxe_pattern': 'XXE',
687
+ 'open_redirect': 'Open Redir',
688
+ 'repeated_failed_auth': 'Auth Fail',
689
+ 'single_endpoint_flood': 'Flood',
690
+ 'sensitive_endpoint_access': 'Sens. Path',
691
+ 'header_anomaly_missing_browser_headers': 'Header Anom',
692
+ 'header_anomaly_missing_modern_headers': 'Modern Anom',
693
+ 'identity_ip_change': 'IP Drift',
694
+ 'identity_user_agent_change': 'UA Drift',
695
+ 'identity_fingerprint_change': 'Fp Drift',
696
+ 'scanner_ua_sqlmap': 'SQLMap',
697
+ 'scanner_ua_nikto': 'Nikto',
698
+ 'scanner_ua_masscan': 'Masscan',
699
+ 'scanner_ua_hydra': 'Hydra',
700
+ 'scanner_ua_headless_chrome': 'Headless',
701
+ 'scanner_ua_puppeteer': 'Puppeteer',
702
+ 'scanner_ua_python_requests': 'Py-Requests',
703
+ 'rate_limit_exceeded': 'Rate Limit',
704
+ 'blocked_ip': 'Blocked IP'
705
+ };
706
+
707
+ function formatThreatLabel(name) {
708
+ if (!name) return 'Unknown';
709
+ if (THREAT_SHORT_LABELS[name]) return THREAT_SHORT_LABELS[name];
710
+ const cleaned = name.replace(/^scanner_ua_/, '').replace(/_/g, ' ');
711
+ return cleaned.length > 12 ? cleaned.slice(0, 11) + '..' : cleaned;
712
+ }
713
+
714
+ // -------------------------------------------------------------------------
715
+ // Navigation & Panel Memory
716
+ // -------------------------------------------------------------------------
717
+
718
+ const PAGE_TITLES = {
719
+ overview: { title: 'Overview', sub: 'Real-time API traffic and threat monitoring' },
720
+ alerts: { title: 'Suspicious Alerts', sub: 'Active suspicious clients under observation' },
721
+ events: { title: 'Security Events Log', sub: 'Click on any event row to expand complete details' },
722
+ clients: { title: 'Client Identity Monitoring', sub: 'Client fingerprinting and identity drift history' },
723
+ blocked: { title: 'Blocked IP Management', sub: 'Manage active and historical IP restrictions' },
724
+ settings: { title: 'System Settings', sub: 'Configure security modes, thresholds, and redaction' }
725
+ };
726
+
727
+ function showPanel(id) {
728
+ const targetId = id || 'overview';
729
+ localStorage.setItem('iri_active_panel', targetId);
730
+
731
+ document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
732
+ const panel = document.getElementById('panel-' + targetId);
733
+ if (panel) panel.classList.remove('hidden');
734
+
735
+ document.querySelectorAll('.nav-item').forEach(b => {
736
+ b.classList.toggle('active', b.dataset.panel === targetId);
737
+ });
738
+
739
+ const meta = PAGE_TITLES[targetId] || { title: targetId, sub: '' };
740
+ document.getElementById('pageTitle').textContent = meta.title;
741
+ document.getElementById('pageSubtitle').textContent = meta.sub;
742
+
743
+ // Always load live stats in background so header, footer and badges are updated
744
+ loadStats();
745
+
746
+ if (targetId === 'events') loadEvents(currentPage.events);
747
+ if (targetId === 'clients') loadClients(currentPage.clients);
748
+ if (targetId === 'blocked') loadBlocked(currentPage.blocked);
749
+ if (targetId === 'alerts') loadAlerts(currentPage.alerts);
750
+ if (targetId === 'settings') loadSettings();
751
+ }
752
+
753
+ document.querySelectorAll('.nav-item').forEach(btn => {
754
+ btn.addEventListener('click', () => showPanel(btn.dataset.panel));
755
+ });
756
+
757
+ // -------------------------------------------------------------------------
758
+ // Security Mode Interactive UI
759
+ // -------------------------------------------------------------------------
760
+
761
+ function selectMode(mode) {
762
+ const targetMode = mode || 'medium';
763
+ document.querySelectorAll('.mode-card').forEach(card => {
764
+ const input = card.querySelector('input[name="security"]');
765
+ const isMatch = input && input.value === targetMode;
766
+ if (input) input.checked = isMatch;
767
+ card.classList.toggle('selected', isMatch);
768
+ });
769
+ const sidebar = document.getElementById('sidebarMode');
770
+ if (sidebar) sidebar.textContent = 'Mode: ' + targetMode;
771
+ }
772
+
773
+ document.querySelectorAll('.mode-card').forEach(card => {
774
+ card.addEventListener('click', () => {
775
+ const input = card.querySelector('input[name="security"]');
776
+ if (input) selectMode(input.value);
777
+ });
778
+ });
779
+
780
+ // -------------------------------------------------------------------------
781
+ // Reactive Block / Unblock Helpers
782
+ // -------------------------------------------------------------------------
783
+
784
+ function renderIpAction(ip, reason = '') {
785
+ if (!ip || ip === '-' || ip === 'unknown') return '';
786
+ const isBlocked = activeBlockedIps.has(ip);
787
+ if (isBlocked) {
788
+ return \`<span class="inline-flex items-center gap-1.5" data-ip-ctrl="\${clean(ip)}">
789
+ <span class="badge badge-critical text-[10px] font-semibold">Blocked</span>
790
+ <button class="btn-unblock text-[11px]" onclick="event.stopPropagation(); doToggleBlock('\${clean(ip)}', false)">Unblock</button>
791
+ </span>\`;
792
+ } else {
793
+ return \`<span class="inline-flex items-center gap-1.5" data-ip-ctrl="\${clean(ip)}">
794
+ <button class="btn-block-small text-[11px]" onclick="event.stopPropagation(); doToggleBlock('\${clean(ip)}', true, '\${clean(reason)}')">Block IP</button>
795
+ </span>\`;
796
+ }
797
+ }
798
+
799
+ function updateIpControlsOnPage(ip, isBlocked) {
800
+ document.querySelectorAll(\`[data-ip-ctrl="\${ip}"]\`).forEach(el => {
801
+ if (isBlocked) {
802
+ el.innerHTML = \`
803
+ <span class="badge badge-critical text-[10px] font-semibold">Blocked</span>
804
+ <button class="btn-unblock text-[11px]" onclick="event.stopPropagation(); doToggleBlock('\${clean(ip)}', false)">Unblock</button>
805
+ \`;
806
+ } else {
807
+ el.innerHTML = \`
808
+ <button class="btn-block-small text-[11px]" onclick="event.stopPropagation(); doToggleBlock('\${clean(ip)}', true)">Block IP</button>
809
+ \`;
810
+ }
811
+ });
812
+ }
813
+
814
+ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', durationMs = 86400000) {
815
+ if (!ip) return;
816
+ const actionName = shouldBlock ? 'Block' : 'Unblock';
817
+ if (!confirm(\`\${actionName} IP \${ip}?\`)) return;
818
+
819
+ // Immediate optimistic update
820
+ if (shouldBlock) {
821
+ activeBlockedIps.add(ip);
822
+ } else {
823
+ activeBlockedIps.delete(ip);
824
+ }
825
+ updateIpControlsOnPage(ip, shouldBlock);
826
+
827
+ try {
828
+ if (shouldBlock) {
829
+ await fetch(BASE + '/api/block', {
830
+ method: 'POST',
831
+ headers: { 'content-type': 'application/json' },
832
+ body: JSON.stringify({ ip, reason: reason || 'manual_action', durationMs })
833
+ });
834
+ } else {
835
+ await fetch(BASE + '/api/unblock', {
836
+ method: 'POST',
837
+ headers: { 'content-type': 'application/json' },
838
+ body: JSON.stringify({ ip })
839
+ });
840
+ }
841
+
842
+ // Refresh active panel data to reflect changes immediately
843
+ const activeTab = localStorage.getItem('iri_active_panel') || 'overview';
844
+ if (activeTab === 'blocked') loadBlocked(currentPage.blocked);
845
+ if (activeTab === 'alerts') loadAlerts(currentPage.alerts);
846
+ if (activeTab === 'events') loadEvents(currentPage.events);
847
+ loadStats();
848
+ } catch (e) {
849
+ console.error('Toggle block error', e);
850
+ if (shouldBlock) activeBlockedIps.delete(ip); else activeBlockedIps.add(ip);
851
+ updateIpControlsOnPage(ip, !shouldBlock);
852
+ }
853
+ }
854
+
855
+ // -------------------------------------------------------------------------
856
+ // Stats (Header, Footer, Badges & Overview)
857
+ // -------------------------------------------------------------------------
858
+
859
+ async function loadStats() {
860
+ try {
861
+ const res = await fetch(BASE + '/api/stats');
862
+ const data = await res.json();
863
+
864
+ refreshMs = Number(data.config?.dashboard?.refreshMs || refreshMs);
865
+ document.getElementById('refreshLabel').textContent = Math.round(refreshMs / 1000) + 's';
866
+ document.getElementById('updatedAt').textContent = 'Updated ' + new Date().toLocaleTimeString();
867
+ document.getElementById('storageMode').textContent = data.storageMode ? 'Storage: ' + data.storageMode : 'Storage: memory';
868
+
869
+ if (data.config?.security) {
870
+ selectMode(data.config.security);
871
+ }
872
+
873
+ // Sync active blocked IPs set
874
+ activeBlockedIps = new Set((data.blockedIps || []).map(b => b.ip));
875
+
876
+ // Stat cards
877
+ setText('totalRequests', fmt.format(data.totalRequests || 0));
878
+ setText('detectedThreats', fmt.format(data.detectedThreats || 0));
879
+ const activeBlockedCount = (data.blockedIps || []).length;
880
+ setText('blockedCombined', fmt.format(data.blockedRequests || 0) + ' req / ' + fmt.format(activeBlockedCount) + ' active');
881
+ setText('activeAlerts', fmt.format(data.activeAlerts || 0));
882
+
883
+ // Mini stats
884
+ setText('avgLatency', (data.averageLatencyMs || 0) + ' ms');
885
+ setText('anomalyEvents', fmt.format(data.anomalyEvents || 0));
886
+ setText('totalRedactions', fmt.format(data.redactions || 0));
887
+ setText('blockedIpsCount', fmt.format(activeBlockedCount));
888
+
889
+ document.getElementById('redactionsLabel').textContent = fmt.format(data.redactions || 0) + ' redactions';
890
+
891
+ // Badges in sidebar
892
+ const alertBadge = document.getElementById('alertBadge');
893
+ if (alertBadge) {
894
+ if (data.activeAlerts > 0) {
895
+ alertBadge.textContent = data.activeAlerts;
896
+ alertBadge.classList.remove('hidden');
897
+ } else {
898
+ alertBadge.classList.add('hidden');
899
+ }
900
+ }
901
+
902
+ const blockedBadge = document.getElementById('blockedBadge');
903
+ if (blockedBadge) {
904
+ if (activeBlockedCount > 0) {
905
+ blockedBadge.textContent = activeBlockedCount;
906
+ blockedBadge.classList.remove('hidden');
907
+ } else {
908
+ blockedBadge.classList.add('hidden');
909
+ }
910
+ }
911
+
912
+ const currentActive = localStorage.getItem('iri_active_panel') || 'overview';
913
+ if (currentActive === 'overview') {
914
+ renderEndpoints(data.endpoints || []);
915
+ renderChart(data.threatDistribution || []);
916
+ }
917
+ } catch(e) { console.error('Stats error', e); }
918
+ schedule();
919
+ }
920
+
921
+ function setText(id, text) {
922
+ const el = document.getElementById(id);
923
+ if (el) el.textContent = text;
924
+ }
925
+
926
+ // -------------------------------------------------------------------------
927
+ // Top Endpoints
928
+ // -------------------------------------------------------------------------
929
+
930
+ function renderEndpoints(rows) {
931
+ const tbody = document.getElementById('endpointRows');
932
+ if (!tbody) return;
933
+ if (!rows.length) {
934
+ tbody.innerHTML = '<tr><td class="py-3 text-gray-400 text-sm" colspan="4">No traffic recorded yet</td></tr>';
935
+ return;
936
+ }
937
+ tbody.innerHTML = rows.map(r => {
938
+ const fullPath = (r.method || 'GET') + ' ' + (r.endpoint || '/');
939
+ return \`<tr class="border-t border-gray-50 hover:bg-gray-50">
940
+ <td class="py-2 pr-3 text-gray-700 text-xs font-mono max-w-[200px] truncate" title="\${clean(fullPath)}">\${clean(fullPath)}</td>
941
+ <td class="py-2 pr-3 text-right text-gray-900 font-medium">\${fmt.format(r.count)}</td>
942
+ <td class="py-2 pr-3 text-right \${r.errors > 0 ? 'text-red-600 font-semibold' : 'text-gray-500'}">\${r.errors}</td>
943
+ <td class="py-2 text-right text-gray-500">\${Number(r.avgLatencyMs||0).toFixed(1)} ms</td>
944
+ </tr>\`;
945
+ }).join('');
946
+ }
947
+
948
+ // -------------------------------------------------------------------------
949
+ // Security Events (with interactive expand on click and reactive block button)
950
+ // -------------------------------------------------------------------------
951
+
952
+ async function loadEvents(page) {
953
+ currentPage.events = page || currentPage.events;
954
+ const risk = document.getElementById('eventRiskFilter')?.value || '';
955
+ try {
956
+ const url = BASE + '/api/events?page=' + currentPage.events + '&perPage=20' + (risk ? '&riskLevel=' + risk : '');
957
+ const res = await fetch(url);
958
+ const data = await res.json();
959
+ rawEventsData = data.data || [];
960
+ const tbody = document.getElementById('eventRows');
961
+ if (!rawEventsData.length) {
962
+ tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No security events recorded</td></tr>';
963
+ } else {
964
+ tbody.innerHTML = rawEventsData.map((r, idx) => {
965
+ const fullEndpoint = (r.method || '') + ' ' + (r.endpoint || '');
966
+ const timeStr = r.timestamp ? new Date(r.timestamp).toLocaleString() : '-';
967
+ const confidenceVal = r.confidence || 0;
968
+ const confBadgeClass = confidenceVal >= 85 ? 'badge-critical' : confidenceVal >= 70 ? 'badge-high' : 'badge-medium';
969
+ const breakdown = Array.isArray(r.breakdown) ? r.breakdown : [];
970
+ const corr = r.correlatedAttack || null;
971
+
972
+ // Build breakdown rows
973
+ const breakdownRows = breakdown.length > 0
974
+ ? breakdown.map(b => \`<tr class="border-t border-gray-100">
975
+ <td class="py-1.5 pr-3 text-gray-700 font-medium">\${clean(b.label)}</td>
976
+ <td class="py-1.5 pr-3 text-gray-500 text-[11px]">\${clean(b.category)}</td>
977
+ <td class="py-1.5 pr-3 text-right">
978
+ <span class="font-mono font-bold \${b.points >= 30 ? 'text-red-600' : b.points >= 20 ? 'text-amber-600' : 'text-gray-700'}">+\${b.points}</span>
979
+ </td>
980
+ <td class="py-1.5 text-right text-gray-400 text-[11px]">\${b.confidence || 0}% conf</td>
981
+ </tr>\`).join('')
982
+ : \`<tr><td colspan="4" class="py-2 text-gray-400 italic">No breakdown available</td></tr>\`;
983
+
984
+ // Correlation banner
985
+ const corrBanner = corr ? \`
986
+ <div class="mt-3 rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 flex items-start gap-2">
987
+ <span class="text-red-600 text-base mt-0.5">🔗</span>
988
+ <div>
989
+ <div class="font-semibold text-red-700 text-xs">Attack Chain Detected: \${clean(corr.label)}</div>
990
+ <div class="text-red-500 text-[11px] mt-0.5">Confidence: \${corr.confidence}% · Sequence length: \${corr.sequenceLength} requests · +\${corr.riskBonus} risk pts</div>
991
+ </div>
992
+ </div>\` : '';
993
+
994
+ return \`<tr class="event-row border-t border-gray-100 hover:bg-blue-50/40" onclick="toggleEventDetail('\${clean(r.id || idx)}')" id="row-\${clean(r.id || idx)}">
995
+ <td class="px-3 py-2.5 text-gray-400 text-xs text-center">
996
+ <span id="chevron-\${clean(r.id || idx)}" class="inline-block transition-transform duration-200">▶</span>
997
+ </td>
998
+ <td class="px-4 py-2.5 text-gray-500 text-xs whitespace-nowrap" title="\${clean(r.timestamp)}">\${clean(timeStr)}</td>
999
+ <td class="px-4 py-2.5 text-gray-800 font-mono text-xs font-medium" title="\${clean(r.ip)}">\${clean(r.ip)}</td>
1000
+ <td class="px-4 py-2.5 text-gray-700 font-mono text-xs max-w-[160px] truncate" title="\${clean(fullEndpoint)}">\${clean(fullEndpoint)}</td>
1001
+ <td class="px-4 py-2.5 text-amber-800 text-xs font-medium max-w-[160px] truncate" title="\${clean(r.threat)}">\${clean(r.threat)}</td>
1002
+ <td class="px-4 py-2.5">
1003
+ <span class="badge badge-\${r.riskLevel}" title="Risk score: \${r.riskScore}">\${clean(r.riskLevel)}</span>
1004
+ \${confidenceVal > 0 ? \`<span class="badge \${confBadgeClass} ml-1" title="Detection confidence">\${confidenceVal}%</span>\` : ''}
1005
+ </td>
1006
+ <td class="px-4 py-2.5 text-right font-bold text-gray-900">\${r.riskScore}</td>
1007
+ <td class="px-4 py-2.5 text-gray-600 text-xs" title="\${clean(r.action)}">\${clean(r.action)}</td>
1008
+ </tr>
1009
+ <!-- Expandable details sub-row -->
1010
+ <tr id="detail-\${clean(r.id || idx)}" class="hidden bg-slate-50 border-b border-gray-200">
1011
+ <td colspan="8" class="px-6 py-4">
1012
+ <div class="rounded-lg bg-white border border-gray-200 p-4 space-y-3 text-xs">
1013
+ <div class="flex items-center justify-between border-b border-gray-100 pb-2">
1014
+ <span class="font-semibold text-gray-900 text-sm">Security Event Details</span>
1015
+ <div class="flex items-center gap-2">
1016
+ \${confidenceVal > 0 ? \`<span class="badge \${confBadgeClass}" title="Overall detection confidence">Confidence: \${confidenceVal}%</span>\` : ''}
1017
+ <span class="font-mono text-gray-400 text-xs">Request ID: \${clean(r.requestId || r.id || 'N/A')}</span>
1018
+ </div>
1019
+ </div>
1020
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
1021
+ <div>
1022
+ <span class="text-gray-400 block">IP Address:</span>
1023
+ <div class="flex items-center gap-2 mt-0.5">
1024
+ <span class="font-mono text-gray-900 font-semibold">\${clean(r.ip)}</span>
1025
+ \${renderIpAction(r.ip, r.threat || r.reason)}
1026
+ </div>
1027
+ </div>
1028
+ <div>
1029
+ <span class="text-gray-400 block">Client ID:</span>
1030
+ <span class="font-mono text-gray-800">\${clean(r.clientId || 'N/A')}</span>
1031
+ </div>
1032
+ <div>
1033
+ <span class="text-gray-400 block">User ID:</span>
1034
+ <span class="text-gray-800 font-medium">\${clean(r.userId || 'Anonymous / Unauthenticated')}</span>
1035
+ </div>
1036
+ <div>
1037
+ <span class="text-gray-400 block">Session ID:</span>
1038
+ <span class="font-mono text-gray-800">\${clean(r.sessionId || 'None')}</span>
1039
+ </div>
1040
+ <div>
1041
+ <span class="text-gray-400 block">Device ID / Platform:</span>
1042
+ <span class="text-gray-800">\${clean((r.deviceId || '') + (r.platform ? ' (' + r.platform + ')' : '')) || 'N/A'}</span>
1043
+ </div>
1044
+ <div>
1045
+ <span class="text-gray-400 block">Hardware Fingerprint:</span>
1046
+ <span class="font-mono text-gray-800">\${clean(r.fingerprint || 'N/A')}</span>
1047
+ </div>
1048
+ </div>
1049
+ <div class="border-t border-gray-100 pt-2">
1050
+ <span class="text-gray-400 block mb-0.5">User-Agent Header:</span>
1051
+ <p class="font-mono text-gray-700 bg-gray-50 p-2 rounded border border-gray-100 break-all select-all">\${clean(r.userAgent || 'None')}</p>
1052
+ </div>
1053
+ <div>
1054
+ <span class="text-gray-400 block mb-0.5">Detected Threat Rules &amp; Trigger Reasons:</span>
1055
+ <p class="text-amber-900 bg-amber-50/70 p-2 rounded border border-amber-200 font-medium">\${clean(r.reason || r.threat || 'None')}</p>
1056
+ </div>
1057
+ <!-- Risk Score Breakdown Table -->
1058
+ <div class="border-t border-gray-100 pt-2">
1059
+ <div class="flex items-center justify-between mb-2">
1060
+ <span class="text-gray-600 font-semibold"> Risk Score Breakdown</span>
1061
+ <span class="text-gray-400 font-mono text-[11px]">Total: <strong class="text-gray-800">\${r.riskScore}/100</strong></span>
1062
+ </div>
1063
+ <table class="w-full text-xs">
1064
+ <thead>
1065
+ <tr class="text-gray-400 text-left">
1066
+ <th class="pb-1 font-medium">Rule / Signal</th>
1067
+ <th class="pb-1 font-medium">Category</th>
1068
+ <th class="pb-1 font-medium text-right">Points</th>
1069
+ <th class="pb-1 font-medium text-right">Confidence</th>
1070
+ </tr>
1071
+ </thead>
1072
+ <tbody>\${breakdownRows}</tbody>
1073
+ </table>
1074
+ </div>
1075
+ \${corrBanner}
1076
+ <div class="flex items-center justify-between text-gray-400 text-[11px] pt-1">
1077
+ <span>Exact Time: \${clean(r.timestamp)}</span>
1078
+ <span>Mitigation Applied: <strong class="text-gray-700">\${clean(r.action)}</strong> (Risk Score: \${r.riskScore})</span>
1079
+ </div>
1080
+ </div>
1081
+ </td>
1082
+ </tr>\`;
1083
+ }).join('');
1084
+ }
1085
+ renderPagination('eventPagination', data, loadEvents);
1086
+ } catch(e) { console.error('Events error', e); }
1087
+ }
1088
+
1089
+ function toggleEventDetail(id) {
1090
+ const detailRow = document.getElementById('detail-' + id);
1091
+ const chevron = document.getElementById('chevron-' + id);
1092
+ if (detailRow) {
1093
+ const isHidden = detailRow.classList.contains('hidden');
1094
+ detailRow.classList.toggle('hidden');
1095
+ if (chevron) chevron.style.transform = isHidden ? 'rotate(90deg)' : 'rotate(0deg)';
1096
+ }
1097
+ }
1098
+
1099
+ // -------------------------------------------------------------------------
1100
+ // Clients
1101
+ // -------------------------------------------------------------------------
1102
+
1103
+ async function loadClients(page) {
1104
+ currentPage.clients = page || currentPage.clients;
1105
+ try {
1106
+ const res = await fetch(BASE + '/api/clients?page=' + currentPage.clients + '&perPage=20');
1107
+ const data = await res.json();
1108
+ const tbody = document.getElementById('clientRows');
1109
+ if (!data.data?.length) {
1110
+ tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="9">No clients recorded</td></tr>';
1111
+ } else {
1112
+ tbody.innerHTML = data.data.map(r => {
1113
+ const fullIps = (r.ips || []).join(', ');
1114
+ const fullFps = (r.fingerprints || []).join(', ');
1115
+ return \`<tr>
1116
+ <td class="px-4 py-2.5 font-mono text-xs text-gray-800 max-w-[130px] truncate" title="\${clean(r.clientId)}">\${clean(r.clientId)}</td>
1117
+ <td class="px-4 py-2.5 text-xs text-gray-700 max-w-[100px] truncate" title="\${clean(r.userId || 'N/A')}">\${clean(r.userId || '-')}</td>
1118
+ <td class="px-4 py-2.5 font-mono text-xs text-gray-900" title="IPs used: \${clean(fullIps)}">\${clean(r.lastIp || '-')}</td>
1119
+ <td class="px-4 py-2.5 text-right font-semibold text-gray-900">\${fmt.format(r.requestCount)}</td>
1120
+ <td class="px-4 py-2.5 text-right text-gray-600" title="\${clean(fullIps)}">\${(r.ips||[]).length}</td>
1121
+ <td class="px-4 py-2.5 text-right \${r.changes > 2 ? 'text-amber-600 font-bold' : 'text-gray-500'}" title="Identity drift count: \${r.changes}">\${r.changes}</td>
1122
+ <td class="px-4 py-2.5 text-right text-gray-500 font-mono text-xs" title="\${clean(fullFps)}">\${(r.fingerprints||[]).length}</td>
1123
+ <td class="px-4 py-2.5"><span class="badge badge-\${r.lastRisk || 'none'}">\${clean(r.lastRisk||'none')}</span></td>
1124
+ <td class="px-4 py-2.5 text-xs text-gray-400 whitespace-nowrap" title="\${clean(r.lastSeenAt)}">\${r.lastSeenAt ? clean(new Date(r.lastSeenAt).toLocaleTimeString()) : '-'}</td>
1125
+ </tr>\`;
1126
+ }).join('');
1127
+ }
1128
+ renderPagination('clientPagination', data, loadClients);
1129
+ } catch(e) { console.error('Clients error', e); }
1130
+ }
1131
+
1132
+ // -------------------------------------------------------------------------
1133
+ // Blocked IPs (Persistent, History & Filterable)
1134
+ // -------------------------------------------------------------------------
1135
+
1136
+ async function loadBlocked(page) {
1137
+ currentPage.blocked = page || currentPage.blocked;
1138
+ const status = document.getElementById('blockedStatusFilter')?.value || 'all';
1139
+ try {
1140
+ const res = await fetch(BASE + '/api/blocked?page=' + currentPage.blocked + '&perPage=20&status=' + status);
1141
+ const data = await res.json();
1142
+ const tbody = document.getElementById('blockedRows');
1143
+ if (!data.data?.length) {
1144
+ tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No IP addresses recorded in blocklist</td></tr>';
1145
+ } else {
1146
+ tbody.innerHTML = data.data.map(r => {
1147
+ let statusBadge = '';
1148
+ if (r.status === 'permanent') {
1149
+ statusBadge = '<span class="badge badge-blue font-semibold">Permanent</span>';
1150
+ } else if (r.status === 'active' || !r.isExpired) {
1151
+ statusBadge = '<span class="badge badge-critical font-semibold">Active</span>';
1152
+ } else {
1153
+ statusBadge = '<span class="badge badge-none font-medium">Expired</span>';
1154
+ }
1155
+
1156
+ let actionButtons = '';
1157
+ if (r.status === 'active' || r.status === 'permanent' || !r.isExpired) {
1158
+ actionButtons = \`<button class="btn-unblock" onclick="doToggleBlock('\${clean(r.ip)}', false)">Unblock IP</button>\`;
1159
+ } else {
1160
+ actionButtons = \`
1161
+ <div class="flex items-center gap-1.5">
1162
+ <button class="btn-primary text-xs py-1 px-2.5 rounded" onclick="doToggleBlock('\${clean(r.ip)}', true, '\${clean(r.reason)}')">Re-block</button>
1163
+ <button class="btn-dismiss text-xs py-1 px-2 rounded" onclick="doDeleteBlock('\${clean(r.ip)}')">Remove</button>
1164
+ </div>\`;
1165
+ }
1166
+
1167
+ return \`<tr>
1168
+ <td class="px-4 py-2.5 font-mono text-sm text-gray-900 font-semibold" title="\${clean(r.ip)}">\${clean(r.ip)}</td>
1169
+ <td class="px-4 py-2.5 text-xs text-gray-600 max-w-[180px] truncate" title="\${clean(r.reason || 'None')}">\${clean(r.reason || '-')}</td>
1170
+ <td class="px-4 py-2.5 text-right font-bold \${(r.score||0) >= 80 ? 'text-red-600' : 'text-amber-600'}">\${r.score||0}</td>
1171
+ <td class="px-4 py-2.5"><span class="badge \${r.manual ? 'badge-blue' : 'badge-high'}">\${r.manual ? 'Manual' : 'Automated'}</span></td>
1172
+ <td class="px-4 py-2.5">\${statusBadge}</td>
1173
+ <td class="px-4 py-2.5 text-xs text-gray-500" title="\${clean(r.blockedAt)}">\${r.blockedAt ? clean(new Date(r.blockedAt).toLocaleString()) : '-'}</td>
1174
+ <td class="px-4 py-2.5 text-xs text-gray-500" title="\${clean(r.expiresAt)}">\${r.expiresAt ? clean(new Date(r.expiresAt).toLocaleString()) : '<span class="font-semibold text-gray-800">Permanent</span>'}</td>
1175
+ <td class="px-4 py-2.5">\${actionButtons}</td>
1176
+ </tr>\`;
1177
+ }).join('');
1178
+ }
1179
+ renderPagination('blockedPagination', data, loadBlocked);
1180
+ } catch(e) { console.error('Blocked error', e); }
1181
+ }
1182
+
1183
+ async function doDeleteBlock(ip) {
1184
+ if (!confirm('Permanently remove IP ' + ip + ' from block history?')) return;
1185
+ try {
1186
+ await fetch(BASE + '/api/unblock', {
1187
+ method: 'POST',
1188
+ headers: { 'content-type': 'application/json' },
1189
+ body: JSON.stringify({ ip })
1190
+ });
1191
+ activeBlockedIps.delete(ip);
1192
+ updateIpControlsOnPage(ip, false);
1193
+ loadBlocked(currentPage.blocked);
1194
+ loadStats();
1195
+ } catch(e) { console.error('Delete block error', e); }
1196
+ }
1197
+
1198
+ // -------------------------------------------------------------------------
1199
+ // Alerts
1200
+ // -------------------------------------------------------------------------
1201
+
1202
+ async function loadAlerts(page) {
1203
+ currentPage.alerts = page || currentPage.alerts;
1204
+ try {
1205
+ const res = await fetch(BASE + '/api/alerts?page=' + currentPage.alerts + '&perPage=20');
1206
+ const data = await res.json();
1207
+ const tbody = document.getElementById('alertRows');
1208
+ if (!data.data?.length) {
1209
+ tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No active alerts</td></tr>';
1210
+ } else {
1211
+ tbody.innerHTML = data.data.map(r => {
1212
+ const threatsStr = (r.threats || []).join(', ');
1213
+ return \`<tr>
1214
+ <td class="px-4 py-2.5 font-mono text-xs text-gray-800 max-w-[130px] truncate" title="\${clean(r.clientId)}">\${clean(r.clientId)}</td>
1215
+ <td class="px-4 py-2.5 font-mono text-xs text-gray-900" title="\${clean(r.lastIp)}">\${clean(r.lastIp || '-')}</td>
1216
+ <td class="px-4 py-2.5"><span class="badge badge-\${r.lastRisk||'medium'}">\${clean(r.lastRisk||'medium')}</span></td>
1217
+ <td class="px-4 py-2.5 text-right font-bold text-amber-700">\${r.lastScore||0}</td>
1218
+ <td class="px-4 py-2.5 text-xs text-gray-600 max-w-[220px] truncate" title="\${clean(threatsStr)}">\${clean(threatsStr || 'Anomaly')}</td>
1219
+ <td class="px-4 py-2.5 text-xs text-gray-500" title="\${clean(r.lastAlertAt)}">\${r.lastAlertAt ? clean(new Date(r.lastAlertAt).toLocaleString()) : '-'}</td>
1220
+ <td class="px-4 py-2.5 text-right text-gray-800 font-semibold">\${r.count||1}</td>
1221
+ <td class="px-4 py-2.5 flex items-center gap-1.5">
1222
+ \${renderIpAction(r.lastIp, 'alert_action')}
1223
+ <button class="btn-dismiss" onclick="doDismiss('\${clean(r.clientId)}')">Dismiss</button>
1224
+ </td>
1225
+ </tr>\`;
1226
+ }).join('');
1227
+ }
1228
+ renderPagination('alertPagination', data, loadAlerts);
1229
+ } catch(e) { console.error('Alerts error', e); }
1230
+ }
1231
+
1232
+ async function doDismiss(clientId) {
1233
+ try {
1234
+ await fetch(BASE + '/api/alerts/' + encodeURIComponent(clientId) + '/dismiss', { method: 'POST' });
1235
+ loadAlerts(currentPage.alerts);
1236
+ loadStats();
1237
+ } catch(e) { console.error('Dismiss error', e); }
1238
+ }
1239
+
1240
+ // -------------------------------------------------------------------------
1241
+ // Block Modal
1242
+ // -------------------------------------------------------------------------
1243
+
1244
+ function showBlockModal() { document.getElementById('blockModal').classList.remove('hidden'); }
1245
+ function hideBlockModal() { document.getElementById('blockModal').classList.add('hidden'); }
1246
+
1247
+ async function submitBlock() {
1248
+ const ip = document.getElementById('blockIpInput').value.trim();
1249
+ const reason = document.getElementById('blockReasonInput').value.trim() || 'manual_block';
1250
+ const durationMs = Number(document.getElementById('blockDurationInput').value);
1251
+ if (!ip) { alert('Please enter a valid IP address'); return; }
1252
+ hideBlockModal();
1253
+ document.getElementById('blockIpInput').value = '';
1254
+ document.getElementById('blockReasonInput').value = '';
1255
+ await doToggleBlock(ip, true, reason, isNaN(durationMs) ? 86400000 : durationMs);
1256
+ }
1257
+
1258
+ // -------------------------------------------------------------------------
1259
+ // Settings
1260
+ // -------------------------------------------------------------------------
1261
+
1262
+ async function loadSettings() {
1263
+ try {
1264
+ const res = await fetch(BASE + '/api/settings');
1265
+ const data = await res.json();
1266
+ if (data.security) selectMode(data.security);
1267
+ } catch(e) { console.error('Load settings error', e); }
1268
+ }
1269
+
1270
+ document.getElementById('settingsForm').addEventListener('submit', async function(e) {
1271
+ e.preventDefault();
1272
+ const fd = new FormData(e.target);
1273
+ const payload = {};
1274
+ for (const [key, value] of fd.entries()) setDeep(payload, key, value);
1275
+
1276
+ // Handle unchecked checkboxes
1277
+ ['rateLimit.enabled','block.enabled','redaction.enabled','testing.enabled','testing.allowClientOverrides','helmet.enabled','alert.enabled'].forEach(key => {
1278
+ if (!fd.has(key)) setDeep(payload, key, false);
1279
+ });
1280
+
1281
+ // Handle selected security mode radio
1282
+ const modeEl = document.querySelector('input[name="security"]:checked');
1283
+ if (modeEl) payload.security = modeEl.value;
1284
+
1285
+ try {
1286
+ const res = await fetch(BASE + '/api/settings', {
1287
+ method: 'POST',
1288
+ headers: { 'content-type': 'application/json' },
1289
+ body: JSON.stringify(payload)
1290
+ });
1291
+ const updated = await res.json();
1292
+ if (updated.security) selectMode(updated.security);
1293
+
1294
+ const msg = document.getElementById('settingsMsg');
1295
+ msg.textContent = '✓ Settings successfully saved and applied';
1296
+ setTimeout(() => { msg.textContent = ''; }, 3500);
1297
+ loadStats();
1298
+ } catch(e) { console.error('Settings error', e); }
1299
+ });
1300
+
1301
+ // -------------------------------------------------------------------------
1302
+ // Chart (Threat Distribution with short labels & full tooltips)
1303
+ // -------------------------------------------------------------------------
1304
+
1305
+ function renderChart(rows) {
1306
+ const labels = rows.map(r => formatThreatLabel(r.name));
1307
+ const values = rows.map(r => r.count);
1308
+ const colors = rows.map((_, i) => ['#2563eb','#f59e0b','#ef4444','#10b981','#8b5cf6','#06b6d4','#f97316'][i % 7]);
1309
+
1310
+ if (!chart) {
1311
+ const canvas = document.getElementById('threatChart');
1312
+ if (!canvas) return;
1313
+ const ctx = canvas.getContext('2d');
1314
+ chart = new Chart(ctx, {
1315
+ type: 'bar',
1316
+ data: {
1317
+ labels,
1318
+ datasets: [{
1319
+ label: 'Threat Count',
1320
+ data: values,
1321
+ backgroundColor: colors,
1322
+ borderRadius: 4,
1323
+ maxBarThickness: 32
1324
+ }]
1325
+ },
1326
+ options: {
1327
+ responsive: true,
1328
+ maintainAspectRatio: false,
1329
+ plugins: {
1330
+ legend: { display: false },
1331
+ tooltip: {
1332
+ callbacks: {
1333
+ title: function(items) {
1334
+ if (!items.length) return '';
1335
+ const idx = items[0].dataIndex;
1336
+ return rows[idx] ? rows[idx].name : items[0].label;
1337
+ },
1338
+ label: function(item) {
1339
+ return 'Detected count: ' + item.parsed.y;
1340
+ }
1341
+ }
1342
+ }
1343
+ },
1344
+ scales: {
1345
+ x: {
1346
+ ticks: {
1347
+ color: '#64748b',
1348
+ font: { size: 11, family: 'Inter' },
1349
+ maxRotation: 20,
1350
+ minRotation: 0
1351
+ },
1352
+ grid: { display: false }
1353
+ },
1354
+ y: {
1355
+ beginAtZero: true,
1356
+ ticks: {
1357
+ color: '#94a3b8',
1358
+ font: { size: 11, family: 'Inter' },
1359
+ precision: 0
1360
+ },
1361
+ grid: { color: '#f1f5f9' }
1362
+ }
1363
+ }
1364
+ }
1365
+ });
1366
+ } else {
1367
+ chart.data.labels = labels;
1368
+ chart.data.datasets[0].data = values;
1369
+ chart.data.datasets[0].backgroundColor = colors;
1370
+ chart.update('none');
1371
+ }
1372
+ }
1373
+
1374
+ // -------------------------------------------------------------------------
1375
+ // Pagination Helper
1376
+ // -------------------------------------------------------------------------
1377
+
1378
+ function renderPagination(containerId, pageData, loadFn) {
1379
+ const container = document.getElementById(containerId);
1380
+ if (!container || pageData.totalPages <= 1) {
1381
+ if (container) container.innerHTML = '';
1382
+ return;
1383
+ }
1384
+ const { page, totalPages, total, perPage } = pageData;
1385
+ const start = (page - 1) * perPage + 1;
1386
+ const end = Math.min(page * perPage, total);
1387
+ let html = \`<div class="flex items-center justify-between gap-3 flex-wrap">
1388
+ <span class="text-xs text-gray-500 font-medium">Showing <strong>\${start}-\${end}</strong> of <strong>\${fmt.format(total)}</strong> records</span>
1389
+ <div class="pagination">\`;
1390
+
1391
+ if (page > 1) {
1392
+ html += \`<button class="page-btn" onclick="(\${loadFn.name})(1)" title="First page">«</button>\`;
1393
+ html += \`<button class="page-btn" onclick="(\${loadFn.name})(\${page - 1})" title="Previous page">‹</button>\`;
1394
+ }
1395
+
1396
+ const start_p = Math.max(1, page - 2);
1397
+ const end_p = Math.min(totalPages, page + 2);
1398
+ for (let p = start_p; p <= end_p; p++) {
1399
+ html += \`<button class="page-btn \${p === page ? 'active' : ''}" onclick="(\${loadFn.name})(\${p})">\${p}</button>\`;
1400
+ }
1401
+
1402
+ if (page < totalPages) {
1403
+ html += \`<button class="page-btn" onclick="(\${loadFn.name})(\${page + 1})" title="Next page">›</button>\`;
1404
+ html += \`<button class="page-btn" onclick="(\${loadFn.name})(\${totalPages})" title="Last page">»</button>\`;
1405
+ }
1406
+ html += '</div></div>';
1407
+ container.innerHTML = html;
1408
+ }
1409
+
1410
+ // -------------------------------------------------------------------------
1411
+ // Helpers
1412
+ // -------------------------------------------------------------------------
1413
+
1414
+ function schedule() {
1415
+ clearTimeout(timer);
1416
+ timer = setTimeout(loadStats, Math.max(5000, refreshMs));
1417
+ }
1418
+
1419
+ function clean(v) {
1420
+ return String(v || '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
1421
+ }
1422
+
1423
+ function setDeep(target, path, value) {
1424
+ const parts = path.split('.');
1425
+ let cur = target;
1426
+ while (parts.length > 1) {
1427
+ const p = parts.shift();
1428
+ cur[p] = cur[p] || {};
1429
+ cur = cur[p];
1430
+ }
1431
+ cur[parts[0]] = value;
1432
+ }
1433
+
1434
+ // -------------------------------------------------------------------------
1435
+ // Initialization
1436
+ // -------------------------------------------------------------------------
1437
+
1438
+ const savedPanel = localStorage.getItem('iri_active_panel') || 'overview';
1439
+ showPanel(savedPanel);
1440
+ </script>
1441
+
1442
+ </body>
1443
+ </html>`;
1444
+ }
1445
+
1446
+ // =============================================================================
1447
+ // Login page
1448
+ // =============================================================================
1449
+
1450
+ function renderLogin(config, failed) {
1451
+ return `<!doctype html>
1452
+ <html lang="en">
1453
+ <head>
1454
+ <meta charset="utf-8" />
1455
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1456
+ <title>iri-shield — Login</title>
1457
+ <script src="https://cdn.tailwindcss.com"></script>
1458
+ </head>
1459
+ <body class="min-h-screen bg-gray-50 text-gray-900">
1460
+ <main class="flex min-h-screen items-center justify-center px-4">
1461
+ <div class="w-full max-w-sm">
1462
+ <div class="text-center mb-8">
1463
+ <div class="inline-flex w-12 h-12 rounded-xl bg-blue-600 items-center justify-center mb-3 shadow-md">
1464
+ <svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1465
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
1466
+ </svg>
1467
+ </div>
1468
+ <h1 class="text-2xl font-bold text-gray-900">iri-shield</h1>
1469
+ <p class="text-sm text-gray-500 mt-1">Enterprise Security Dashboard</p>
1470
+ </div>
1471
+ <form method="post" action="${escapeHtml(config.dashboard?.path || '/iri-shield')}/login" class="bg-white rounded-2xl border border-gray-200 shadow-sm p-6">
1472
+ ${failed ? '<div class="mb-4 rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700 font-medium">Invalid username or password.</div>' : ''}
1473
+ <label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username</label>
1474
+ <input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 mb-4" id="username" name="username" autocomplete="username" required />
1475
+ <label class="block text-sm font-medium text-gray-700 mb-1" for="password">Password</label>
1476
+ <input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 mb-5" id="password" name="password" type="password" autocomplete="current-password" required />
1477
+ <button class="w-full rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white hover:bg-blue-700 transition-colors shadow-sm" type="submit">Sign in</button>
1478
+ </form>
1479
+ <p class="text-center text-xs text-gray-400 mt-4">${escapeHtml(config.appName || 'iri-shield')} Security Platform</p>
1480
+ </div>
1481
+ </main>
1482
+ </body>
1483
+ </html>`;
1484
+ }
1485
+
1486
+ // =============================================================================
1487
+ // HTML helpers
1488
+ // =============================================================================
1489
+
1490
+ function navItem(panel, label, icon, badgeId = null) {
1491
+ const badge = badgeId
1492
+ ? `<span id="${badgeId}" class="ml-auto text-xs font-bold bg-red-100 text-red-600 border border-red-200 rounded-full px-2 py-0.5 hidden"></span>`
1493
+ : '';
1494
+ return `<button class="nav-item w-full flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900" data-panel="${panel}">
1495
+ <span class="nav-icon w-4 h-4 shrink-0 text-gray-400">${icon}</span>
1496
+ <span>${label}</span>
1497
+ ${badge}
1498
+ </button>`;
1499
+ }
1500
+
1501
+ function statCard(id, label, icon, colorClass, bgClass) {
1502
+ return `<div class="stat-card bg-white rounded-xl border border-gray-200 p-5 shadow-xs">
1503
+ <div class="flex items-start justify-between">
1504
+ <div>
1505
+ <p class="text-xs font-medium text-gray-500 uppercase tracking-wide">${label}</p>
1506
+ <p class="mt-2 text-2xl font-bold ${colorClass}" id="${id}">—</p>
1507
+ </div>
1508
+ <div class="w-9 h-9 ${bgClass} rounded-lg flex items-center justify-center shrink-0 border border-gray-100">${icon}</div>
1509
+ </div>
1510
+ </div>`;
1511
+ }
1512
+
1513
+ function miniStat(id, label, suffix) {
1514
+ return `<div class="bg-white rounded-xl border border-gray-200 px-4 py-3 flex items-center justify-between shadow-xs">
1515
+ <span class="text-sm text-gray-500 font-medium">${label}</span>
1516
+ <span class="text-sm font-bold text-gray-900" id="${id}">—</span>
1517
+ </div>`;
1518
+ }
1519
+
1520
+ function modeCard(value, title, desc, selected) {
1521
+ return `<label class="mode-card${selected ? ' selected' : ''} block" data-mode="${value}">
1522
+ <input type="radio" name="security" value="${value}" class="sr-only" ${selected ? 'checked' : ''} />
1523
+ <div class="flex items-center justify-between mb-1">
1524
+ <span class="font-semibold text-gray-900 text-sm">${title}</span>
1525
+ <span class="mode-radio-icon w-3.5 h-3.5 rounded-full border-2 border-gray-300 inline-block"></span>
1526
+ </div>
1527
+ <p class="text-xs text-gray-500 leading-relaxed">${desc}</p>
1528
+ </label>`;
1529
+ }
1530
+
1531
+ function numInput(label, name, value) {
1532
+ return `<label class="block">
1533
+ <span class="text-sm font-medium text-gray-700">${label}</span>
1534
+ <input class="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900" type="number" name="${name}" value="${escapeHtml(String(value ?? ''))}" />
1535
+ </label>`;
1536
+ }
1537
+
1538
+ function checkInput(label, name, checked) {
1539
+ return `<label class="flex items-center gap-3 rounded-lg border border-gray-200 px-3 py-2.5 cursor-pointer hover:bg-gray-50 transition-colors">
1540
+ <input class="w-4 h-4 rounded accent-blue-600 cursor-pointer" type="checkbox" name="${name}" ${checked ? 'checked' : ''} />
1541
+ <span class="text-sm font-medium text-gray-700 select-none">${label}</span>
1542
+ </label>`;
1543
+ }
1544
+
1545
+ // --- SVG Icons ---
1546
+ function iconOverview() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><rect x="3" y="3" width="7" height="7" rx="1" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke-width="2"/></svg>'; }
1547
+ function iconAlerts() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>'; }
1548
+ function iconEvents() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>'; }
1549
+ function iconClients() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'; }
1550
+ function iconBlocked() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><circle cx="12" cy="12" r="10" stroke-width="2"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" stroke-width="2" stroke-linecap="round"/></svg>'; }
1551
+ function iconSettings() { return '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><circle cx="12" cy="12" r="3" stroke-width="2"/></svg>'; }
1552
+ function iconReq() { return '<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>'; }
1553
+ function iconThreat() { return '<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>'; }
1554
+ function iconBlock() { return '<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke-width="2"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" stroke-width="2" stroke-linecap="round"/></svg>'; }
1555
+ function iconAlert() { return '<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>'; }
1556
+
1557
+ // =============================================================================
1558
+ // Auth & settings helpers
1559
+ // =============================================================================
1560
+
1561
+ function dashboardAuth(options = {}) {
1562
+ const username = options.username || 'admin';
1563
+ const password = options.password || 'admin';
1564
+ const token = randomBytes(32).toString('hex');
1565
+ return {
1566
+ token,
1567
+ canLogin(inputUser, inputPass) {
1568
+ return safeEqual(inputUser || '', username) && safeEqual(inputPass || '', password);
1569
+ },
1570
+ isAuthenticated(req) {
1571
+ return parseCookies(req.headers.cookie).iri_shield_session === token;
1572
+ }
1573
+ };
1574
+ }
1575
+
1576
+ function applyDashboardSettings(config, body) {
1577
+ const numberFields = [
1578
+ ['rateLimit.max', 1, 100000],
1579
+ ['rateLimit.windowMs', 1000, 86400000],
1580
+ ['block.threshold', 1, 100],
1581
+ ['block.durationMs', 1000, 86400000 * 30],
1582
+ ['anomaly.mediumThreshold', 1, 100],
1583
+ ['anomaly.highThreshold', 1, 100],
1584
+ ['anomaly.criticalThreshold', 1, 100],
1585
+ ['anomaly.singleEndpointMax', 1, 100000],
1586
+ ['anomaly.failedAuthMax', 1, 100000],
1587
+ ['alert.threshold', 1, 100],
1588
+ ['privacy.retentionDays', 1, 3650],
1589
+ ['dashboard.refreshMs', 5000, 3600000]
1590
+ ];
1591
+ for (const [path, min, max] of numberFields) {
1592
+ const value = getPath(body, path);
1593
+ if (value !== undefined && value !== '') setPath(config, path, clamp(Number(value), min, max));
1594
+ }
1595
+
1596
+ const booleanFields = [
1597
+ 'rateLimit.enabled', 'block.enabled', 'redaction.enabled',
1598
+ 'testing.enabled', 'testing.allowClientOverrides',
1599
+ 'helmet.enabled', 'helmet.contentSecurityPolicy', 'alert.enabled',
1600
+ 'privacy.hashIp',
1601
+ 'rules.sqlInjection', 'rules.xss', 'rules.pathTraversal', 'rules.commandInjection',
1602
+ 'rules.ssti', 'rules.nosqlInjection', 'rules.secretProbe', 'rules.scannerDetection',
1603
+ 'rules.openRedirect', 'rules.xxe', 'rules.headerAnomaly', 'rules.base64Payload'
1604
+ ];
1605
+ for (const path of booleanFields) {
1606
+ const value = getPath(body, path);
1607
+ if (value !== undefined) setPath(config, path, value === true || value === 'true' || value === 'on');
1608
+ }
1609
+
1610
+ if (body.security && ['low', 'medium', 'high'].includes(body.security)) {
1611
+ config.security = body.security;
1612
+ }
1613
+
1614
+ if (body.failureMode && ['fail-open', 'fail-closed'].includes(body.failureMode)) {
1615
+ config.failureMode = body.failureMode;
1616
+ }
1617
+
1618
+ if (typeof body['redaction.fields'] === 'string') {
1619
+ config.redaction.fields = body['redaction.fields'].split(',').map((f) => f.trim()).filter(Boolean);
1620
+ }
1621
+ if (body.redaction?.fields && typeof body.redaction.fields === 'string') {
1622
+ config.redaction.fields = body.redaction.fields.split(',').map((f) => f.trim()).filter(Boolean);
1623
+ }
1624
+ }
1625
+
1626
+ function publicConfig(config) {
1627
+ const copy = JSON.parse(JSON.stringify(config));
1628
+ if (copy.dashboard?.password) copy.dashboard.password = '';
1629
+ return copy;
1630
+ }
1631
+
1632
+ function paginateArray(array, page, perPage) {
1633
+ const total = array.length;
1634
+ const totalPages = Math.ceil(total / perPage) || 1;
1635
+ const safePage = Math.max(1, Math.min(page, totalPages));
1636
+ const start = (safePage - 1) * perPage;
1637
+ return { data: array.slice(start, start + perPage), total, page: safePage, perPage, totalPages };
1638
+ }
1639
+
1640
+ function safeEqual(left, right) {
1641
+ const lb = Buffer.from(String(left));
1642
+ const rb = Buffer.from(String(right));
1643
+ if (lb.length !== rb.length) return false;
1644
+ return timingSafeEqual(lb, rb);
1645
+ }
1646
+
1647
+ function parseCookies(cookieHeader = '') {
1648
+ return String(cookieHeader)
1649
+ .split(';')
1650
+ .reduce((cookies, pair) => {
1651
+ const [rawKey, ...rawValue] = pair.trim().split('=');
1652
+ if (!rawKey) return cookies;
1653
+ cookies[decodeURIComponent(rawKey)] = decodeURIComponent(rawValue.join('='));
1654
+ return cookies;
1655
+ }, {});
1656
+ }
1657
+
1658
+ function getPath(object, path) {
1659
+ return path.split('.').reduce((cur, key) => cur?.[key], object);
1660
+ }
1661
+
1662
+ function setPath(object, path, value) {
1663
+ const parts = path.split('.');
1664
+ let cur = object;
1665
+ while (parts.length > 1) { const k = parts.shift(); cur[k] = cur[k] || {}; cur = cur[k]; }
1666
+ cur[parts[0]] = value;
1667
+ }
1668
+
1669
+ function clamp(value, min, max) {
1670
+ if (!Number.isFinite(value)) return min;
1671
+ return Math.max(min, Math.min(max, value));
1672
+ }
1673
+
1674
+ function escapeHtml(value) {
1675
+ return String(value || '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c]));
1676
+ }
1677
+
1678
+ function escapeJsString(value) {
1679
+ return String(value || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
1680
+ }
1681
+
1682
+ module.exports = { createDashboardRouter };