@ijfw/memory-server 1.4.0 → 1.4.3

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,239 @@
1
+ /**
2
+ * dashboard-charts.js — IJFW v1.4.3 W9-C (B19)
3
+ *
4
+ * Pure-JS canvas/DOM chart helpers for the dashboard. No external libs.
5
+ * Theme-aware: reads CSS custom properties `--ijfw-chart-fg`,
6
+ * `--ijfw-chart-bg`, `--ijfw-chart-warning` from the element's computed
7
+ * style. Falls back to sane defaults if the host page hasn't set them.
8
+ *
9
+ * All helpers are defensive against malformed input so a bad payload from
10
+ * the API can never throw inside the dashboard render loop.
11
+ */
12
+
13
+ const DEFAULTS = {
14
+ fg: '#9ad2ff',
15
+ bg: 'rgba(154,210,255,0.18)',
16
+ warning: '#ff9b3a',
17
+ text: '#cfd6dd',
18
+ };
19
+
20
+ function _readTheme(el) {
21
+ // Both `canvas` and plain `div` go through `getComputedStyle`. In the test
22
+ // environment the element is a hand-rolled mock that doesn't reach the DOM
23
+ // — guard so we don't blow up if `getComputedStyle` is unavailable.
24
+ let cs = null;
25
+ try {
26
+ if (el && typeof globalThis.getComputedStyle === 'function') {
27
+ cs = globalThis.getComputedStyle(el);
28
+ }
29
+ } catch { cs = null; }
30
+ const read = (prop, fallback) => {
31
+ if (!cs || typeof cs.getPropertyValue !== 'function') return fallback;
32
+ const v = cs.getPropertyValue(prop);
33
+ return (v && v.trim()) ? v.trim() : fallback;
34
+ };
35
+ return {
36
+ fg: read('--ijfw-chart-fg', DEFAULTS.fg),
37
+ bg: read('--ijfw-chart-bg', DEFAULTS.bg),
38
+ warning: read('--ijfw-chart-warning', DEFAULTS.warning),
39
+ text: read('--ijfw-chart-text', DEFAULTS.text),
40
+ };
41
+ }
42
+
43
+ function _safeNum(v, def = 0) {
44
+ return (typeof v === 'number' && Number.isFinite(v)) ? v : def;
45
+ }
46
+
47
+ /**
48
+ * lineChart(canvas, points, opts)
49
+ * points: [{ x: number, y: number }, ...] OR [number, ...]
50
+ * opts: { xMin?, xMax?, yMax?, color?, fill? }
51
+ *
52
+ * Empty data renders nothing (clears the canvas) without throwing.
53
+ */
54
+ export function lineChart(canvas, points, opts = {}) {
55
+ if (!canvas || typeof canvas.getContext !== 'function') return;
56
+ const ctx = canvas.getContext('2d');
57
+ if (!ctx) return;
58
+ const W = _safeNum(canvas.width, 200);
59
+ const H = _safeNum(canvas.height, 100);
60
+ const theme = _readTheme(canvas);
61
+ const color = opts.color || theme.fg;
62
+ const fill = opts.fill === false ? null : theme.bg;
63
+
64
+ try { ctx.clearRect(0, 0, W, H); } catch {}
65
+
66
+ const pts = Array.isArray(points) ? points : [];
67
+ const norm = pts.map((p, i) => {
68
+ if (typeof p === 'number') return { x: i, y: _safeNum(p, 0) };
69
+ return { x: _safeNum(p && p.x, i), y: _safeNum(p && p.y, 0) };
70
+ }).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y));
71
+
72
+ if (norm.length === 0) return;
73
+
74
+ let xMin = _safeNum(opts.xMin, norm[0].x);
75
+ let xMax = _safeNum(opts.xMax, norm[norm.length - 1].x);
76
+ if (xMax === xMin) xMax = xMin + 1;
77
+ const yMax = _safeNum(opts.yMax, Math.max(1, ...norm.map((p) => p.y)));
78
+ const yScale = yMax > 0 ? yMax : 1;
79
+
80
+ const pad = 4;
81
+ const innerW = Math.max(1, W - pad * 2);
82
+ const innerH = Math.max(1, H - pad * 2);
83
+
84
+ const px = (x) => pad + ((x - xMin) / (xMax - xMin)) * innerW;
85
+ const py = (y) => pad + (1 - (Math.max(0, y) / yScale)) * innerH;
86
+
87
+ // Filled area
88
+ if (fill) {
89
+ try {
90
+ ctx.beginPath();
91
+ ctx.moveTo(px(norm[0].x), H - pad);
92
+ for (const p of norm) ctx.lineTo(px(p.x), py(p.y));
93
+ ctx.lineTo(px(norm[norm.length - 1].x), H - pad);
94
+ ctx.closePath();
95
+ ctx.fillStyle = fill;
96
+ ctx.fill();
97
+ } catch {}
98
+ }
99
+
100
+ // Line stroke
101
+ try {
102
+ ctx.beginPath();
103
+ ctx.moveTo(px(norm[0].x), py(norm[0].y));
104
+ for (let i = 1; i < norm.length; i++) ctx.lineTo(px(norm[i].x), py(norm[i].y));
105
+ ctx.strokeStyle = color;
106
+ ctx.lineWidth = 1.5;
107
+ ctx.stroke();
108
+ } catch {}
109
+ }
110
+
111
+ /**
112
+ * barChart(canvas, bars, opts)
113
+ * bars: [{ label: string, value: number, color?: string }, ...]
114
+ * opts: { horizontal?: boolean, color?: string, maxValue?: number }
115
+ *
116
+ * Zero-value bars render as empty rails. Negative values are clamped to 0.
117
+ */
118
+ export function barChart(canvas, bars, opts = {}) {
119
+ if (!canvas || typeof canvas.getContext !== 'function') return;
120
+ const ctx = canvas.getContext('2d');
121
+ if (!ctx) return;
122
+ const W = _safeNum(canvas.width, 200);
123
+ const H = _safeNum(canvas.height, 100);
124
+ const theme = _readTheme(canvas);
125
+ const defaultColor = opts.color || theme.fg;
126
+ const horizontal = Boolean(opts.horizontal);
127
+
128
+ try { ctx.clearRect(0, 0, W, H); } catch {}
129
+
130
+ const rows = Array.isArray(bars) ? bars.filter((b) => b && typeof b === 'object') : [];
131
+ if (rows.length === 0) return;
132
+
133
+ const values = rows.map((b) => Math.max(0, _safeNum(b.value, 0)));
134
+ const maxVal = _safeNum(opts.maxValue, Math.max(1, ...values));
135
+ const scale = maxVal > 0 ? maxVal : 1;
136
+
137
+ const pad = 4;
138
+ const labelGutter = horizontal ? 80 : 14;
139
+ const innerW = Math.max(1, W - pad * 2 - (horizontal ? labelGutter : 0));
140
+ const innerH = Math.max(1, H - pad * 2 - (horizontal ? 0 : labelGutter));
141
+ const slot = (horizontal ? innerH : innerW) / rows.length;
142
+ const barW = Math.max(1, slot * 0.7);
143
+
144
+ for (let i = 0; i < rows.length; i++) {
145
+ const b = rows[i];
146
+ const v = values[i];
147
+ const color = b.color || defaultColor;
148
+ if (horizontal) {
149
+ const y = pad + i * slot + (slot - barW) / 2;
150
+ const len = (v / scale) * innerW;
151
+ try {
152
+ ctx.fillStyle = theme.bg;
153
+ ctx.fillRect(pad + labelGutter, y, innerW, barW);
154
+ ctx.fillStyle = color;
155
+ ctx.fillRect(pad + labelGutter, y, len, barW);
156
+ ctx.fillStyle = theme.text;
157
+ ctx.fillText(String(b.label || ''), pad, y + barW * 0.75);
158
+ } catch {}
159
+ } else {
160
+ const x = pad + i * slot + (slot - barW) / 2;
161
+ const len = (v / scale) * innerH;
162
+ try {
163
+ ctx.fillStyle = theme.bg;
164
+ ctx.fillRect(x, pad, barW, innerH);
165
+ ctx.fillStyle = color;
166
+ ctx.fillRect(x, pad + (innerH - len), barW, len);
167
+ ctx.fillStyle = theme.text;
168
+ ctx.fillText(String(b.label || '').slice(0, 8), x, H - pad);
169
+ } catch {}
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * progressBar(div, data)
176
+ * data: { current: number, limit: number | null, label?: string, warning?: boolean }
177
+ *
178
+ * Mutates `div` in place. Renders an "unlimited" placeholder when limit is
179
+ * null. Applies a warning class when `warning` is truthy.
180
+ */
181
+ export function progressBar(div, data = {}) {
182
+ if (!div) return;
183
+ const theme = _readTheme(div);
184
+ const cur = Math.max(0, _safeNum(data.current, 0));
185
+ const lim = (data.limit === null || data.limit === undefined) ? null : _safeNum(data.limit, null);
186
+ const label = typeof data.label === 'string' ? data.label : '';
187
+ const warn = Boolean(data.warning);
188
+
189
+ // Clear children.
190
+ try {
191
+ while (div.firstChild) div.removeChild(div.firstChild);
192
+ } catch {
193
+ // Some test mocks omit firstChild/removeChild. Skip cleanup.
194
+ }
195
+
196
+ const setClass = (extra) => {
197
+ try {
198
+ div.className = ['ijfw-progress', extra, warn ? 'ijfw-progress--warn' : '']
199
+ .filter(Boolean).join(' ');
200
+ } catch {}
201
+ };
202
+
203
+ // Helper to create child elements safely.
204
+ function appendEl(tag, opts) {
205
+ try {
206
+ if (typeof div.ownerDocument === 'object' && div.ownerDocument && typeof div.ownerDocument.createElement === 'function') {
207
+ const el = div.ownerDocument.createElement(tag);
208
+ if (opts && opts.text) el.textContent = opts.text;
209
+ if (opts && opts.style) el.setAttribute('style', opts.style);
210
+ if (opts && opts.cls) el.className = opts.cls;
211
+ if (typeof div.appendChild === 'function') div.appendChild(el);
212
+ return el;
213
+ }
214
+ } catch {}
215
+ return null;
216
+ }
217
+
218
+ if (lim === null) {
219
+ setClass('ijfw-progress--unlimited');
220
+ appendEl('span', { cls: 'ijfw-progress-label', text: label });
221
+ appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / unlimited' });
222
+ return;
223
+ }
224
+
225
+ setClass('');
226
+ const denom = lim > 0 ? lim : 1;
227
+ const pct = Math.max(0, Math.min(100, (cur / denom) * 100));
228
+ appendEl('span', { cls: 'ijfw-progress-label', text: label });
229
+ const rail = appendEl('span', { cls: 'ijfw-progress-rail', style: 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle;margin:0 6px' });
230
+ if (rail) {
231
+ try {
232
+ const fill = (rail.ownerDocument || div.ownerDocument).createElement('span');
233
+ fill.className = 'ijfw-progress-fill';
234
+ fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warning : theme.fg));
235
+ rail.appendChild(fill);
236
+ } catch {}
237
+ }
238
+ appendEl('span', { cls: 'ijfw-progress-val', text: cur + ' / ' + lim });
239
+ }
@@ -143,6 +143,15 @@ tr:hover td{background:var(--surface)}
143
143
  .empty{text-align:center;padding:40px 20px;color:var(--fg-dim)}
144
144
  .empty-icon{font-size:32px;margin-bottom:10px;opacity:.4}
145
145
 
146
+ /* Extension events */
147
+ .evt-row{display:flex;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);align-items:flex-start}
148
+ .evt-row:last-child{border-bottom:none}
149
+ .evt-ts{color:var(--fg-dim);white-space:nowrap;font-size:11px;min-width:80px}
150
+ .evt-badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:700;white-space:nowrap}
151
+ .evt-allow{background:rgba(46,204,113,0.15);color:var(--success)}
152
+ .evt-deny{background:rgba(239,68,68,0.15);color:var(--danger)}
153
+ .evt-body{flex:1;color:var(--fg);font-size:11px;word-break:break-all}
154
+
146
155
  /* Memory tree */
147
156
  .mem-layout{display:flex;gap:0;height:calc(100vh - 180px);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
148
157
  .mem-l{width:300px;flex-shrink:0;border-right:1px solid var(--border);display:flex;flex-direction:column;background:var(--bg-elevated)}
@@ -563,8 +572,9 @@ tr:hover td{background:var(--surface)}
563
572
  </div>
564
573
  </div>
565
574
 
566
- <!-- ======== EXTENSIONS (W3/t15) ======== -->
575
+ <!-- ======== EXTENSIONS (W3/t15 + B9) ======== -->
567
576
  <div class="section" data-section="extensions">
577
+ <!-- Sub-section: Installed -->
568
578
  <div class="card">
569
579
  <div class="ctitle"><span id="ext-count">Extensions</span></div>
570
580
  <div id="extensions-content">
@@ -574,6 +584,60 @@ tr:hover td{background:var(--surface)}
574
584
  </div>
575
585
  </div>
576
586
  </div>
587
+
588
+ <!-- Sub-section: Active extension -->
589
+ <div class="card">
590
+ <div class="ctitle">Active Extension</div>
591
+ <div id="ext-active-content">
592
+ <div class="empty"><p style="font-size:13px">Loading...</p></div>
593
+ </div>
594
+ </div>
595
+
596
+ <!-- Sub-section: Charts (B19) -->
597
+ <div class="card">
598
+ <div class="ctitle">Audit Charts</div>
599
+ <div id="ext-charts-content">
600
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:12px">
601
+ <div>
602
+ <div style="color:var(--fg-dim);margin-bottom:4px">Events / hour (24h)</div>
603
+ <canvas id="ext-chart-hourly" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
604
+ </div>
605
+ <div>
606
+ <div style="color:var(--fg-dim);margin-bottom:4px">Deny rate by extension</div>
607
+ <canvas id="ext-chart-byext" width="320" height="80" style="width:100%;height:80px;display:block"></canvas>
608
+ </div>
609
+ <div>
610
+ <div style="color:var(--fg-dim);margin-bottom:4px">Top denied tools</div>
611
+ <canvas id="ext-chart-bytool" width="320" height="120" style="width:100%;height:120px;display:block"></canvas>
612
+ </div>
613
+ <div>
614
+ <div style="color:var(--fg-dim);margin-bottom:4px">Quota usage</div>
615
+ <div id="ext-chart-quotas" style="display:flex;flex-direction:column;gap:6px"></div>
616
+ </div>
617
+ </div>
618
+ </div>
619
+ </div>
620
+
621
+ <!-- Sub-section: Permission events -->
622
+ <div class="card">
623
+ <div class="ctitle" style="justify-content:space-between">
624
+ <span>Permission Events <span id="ext-events-live" style="display:none"><span class="pulse"></span></span></span>
625
+ <span style="display:flex;gap:8px;align-items:center">
626
+ <select id="ext-evt-filter-ext" class="btn-g" style="padding:3px 8px;font-size:11px" aria-label="Filter by extension">
627
+ <option value="">All extensions</option>
628
+ </select>
629
+ <select id="ext-evt-filter-denied" class="btn-g" style="padding:3px 8px;font-size:11px" aria-label="Filter by outcome">
630
+ <option value="">All outcomes</option>
631
+ <option value="true">Denied only</option>
632
+ <option value="false">Allowed only</option>
633
+ </select>
634
+ <button class="btn-g" id="ext-evt-clear" style="font-size:11px" aria-label="Clear events">Clear</button>
635
+ </span>
636
+ </div>
637
+ <div id="ext-events-content" style="max-height:320px;overflow-y:auto;font-size:12px;font-family:ui-monospace,monospace">
638
+ <div class="empty"><p>No permission events yet.</p></div>
639
+ </div>
640
+ </div>
577
641
  </div>
578
642
 
579
643
  <!-- ======== SUBSCRIPTIONS ======== -->
@@ -1303,6 +1367,174 @@ async function loadExtensions() {
1303
1367
  }
1304
1368
  }
1305
1369
 
1370
+ // ====== ACTIVE EXTENSION LOADER (B9) ======
1371
+ async function loadExtensionActive() {
1372
+ var el = document.getElementById('ext-active-content');
1373
+ if (!el) return;
1374
+ try {
1375
+ var r = await fetch('/api/extensions/active');
1376
+ var d = await r.json();
1377
+ while (el.firstChild) el.removeChild(el.firstChild);
1378
+ if (!d.active) {
1379
+ var p = document.createElement('p');
1380
+ p.setAttribute('style', 'font-size:13px;color:var(--fg-dim);padding:4px 0');
1381
+ p.textContent = 'None — bundled IJFW context active';
1382
+ el.appendChild(p);
1383
+ return;
1384
+ }
1385
+ var a = d.active;
1386
+ var wrap = document.createElement('div');
1387
+ wrap.setAttribute('style', 'font-size:13px');
1388
+ var nameRow = document.createElement('div');
1389
+ nameRow.setAttribute('style', 'margin-bottom:6px');
1390
+ var strong = document.createElement('strong');
1391
+ strong.textContent = a.name || 'unknown';
1392
+ nameRow.appendChild(strong);
1393
+ if (a.scope) {
1394
+ var sc = document.createElement('span');
1395
+ sc.setAttribute('style', 'margin-left:8px;font-size:11px;color:var(--fg-dim)');
1396
+ sc.textContent = '(' + a.scope + ')';
1397
+ nameRow.appendChild(sc);
1398
+ }
1399
+ wrap.appendChild(nameRow);
1400
+ if (a.permissions) {
1401
+ var permsEl = document.createElement('div');
1402
+ permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
1403
+ var reads = (a.permissions.reads || []).join(', ') || 'none';
1404
+ var writes = (a.permissions.writes || []).join(', ') || 'none';
1405
+ permsEl.innerHTML = '<b style="color:var(--fg)">reads:</b> ' + reads + ' &nbsp; <b style="color:var(--fg)">writes:</b> ' + writes;
1406
+ wrap.appendChild(permsEl);
1407
+ }
1408
+ el.appendChild(wrap);
1409
+ } catch (err) {
1410
+ if (el) { while (el.firstChild) el.removeChild(el.firstChild); }
1411
+ var errP = document.createElement('p');
1412
+ errP.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
1413
+ errP.textContent = 'Could not load active extension state.';
1414
+ if (el) el.appendChild(errP);
1415
+ }
1416
+ }
1417
+
1418
+ // ====== PERMISSION EVENTS LOADER (B9) ======
1419
+ var _evtSource = null;
1420
+ var _evtRows = [];
1421
+ var _evtExtensions = new Set();
1422
+
1423
+ function _renderEvtRows() {
1424
+ var el = document.getElementById('ext-events-content');
1425
+ if (!el) return;
1426
+ var filterExt = (document.getElementById('ext-evt-filter-ext') || {}).value || '';
1427
+ var filterDenied = (document.getElementById('ext-evt-filter-denied') || {}).value || '';
1428
+ var visible = _evtRows.filter(function(e) {
1429
+ if (filterExt && e.extension !== filterExt) return false;
1430
+ if (filterDenied === 'true' && e.allowed !== false) return false;
1431
+ if (filterDenied === 'false' && e.allowed === false) return false;
1432
+ return true;
1433
+ });
1434
+ while (el.firstChild) el.removeChild(el.firstChild);
1435
+ if (visible.length === 0) {
1436
+ var empty = document.createElement('div');
1437
+ empty.className = 'empty';
1438
+ var ep = document.createElement('p');
1439
+ ep.textContent = 'No permission events match the current filters.';
1440
+ empty.appendChild(ep);
1441
+ el.appendChild(empty);
1442
+ return;
1443
+ }
1444
+ var frag = document.createDocumentFragment();
1445
+ visible.slice(-200).forEach(function(e) {
1446
+ var row = document.createElement('div');
1447
+ row.className = 'evt-row';
1448
+ var ts = document.createElement('span');
1449
+ ts.className = 'evt-ts';
1450
+ ts.textContent = e.ts ? new Date(e.ts).toLocaleTimeString() : '';
1451
+ row.appendChild(ts);
1452
+ var badge = document.createElement('span');
1453
+ badge.className = 'evt-badge ' + (e.allowed === false ? 'evt-deny' : 'evt-allow');
1454
+ badge.textContent = e.allowed === false ? 'DENY' : 'ALLOW';
1455
+ row.appendChild(badge);
1456
+ var body = document.createElement('span');
1457
+ body.className = 'evt-body';
1458
+ body.textContent = (e.extension || '') + ' → ' + (e.tool || e.action || '') + (e.target ? ':' + e.target : '');
1459
+ row.appendChild(body);
1460
+ frag.appendChild(row);
1461
+ });
1462
+ el.appendChild(frag);
1463
+ el.scrollTop = el.scrollHeight;
1464
+ }
1465
+
1466
+ function _addEvtRow(obj) {
1467
+ _evtRows.push(obj);
1468
+ if (obj.extension) {
1469
+ _evtExtensions.add(obj.extension);
1470
+ // Rebuild extension filter options
1471
+ var sel = document.getElementById('ext-evt-filter-ext');
1472
+ if (sel) {
1473
+ var existing = new Set(Array.from(sel.options).map(function(o) { return o.value; }));
1474
+ _evtExtensions.forEach(function(name) {
1475
+ if (!existing.has(name)) {
1476
+ var opt = document.createElement('option');
1477
+ opt.value = name; opt.textContent = name;
1478
+ sel.appendChild(opt);
1479
+ }
1480
+ });
1481
+ }
1482
+ }
1483
+ }
1484
+
1485
+ function loadExtensionEvents() {
1486
+ var liveEl = document.getElementById('ext-events-live');
1487
+
1488
+ // Close any previous SSE connection.
1489
+ if (_evtSource) { try { _evtSource.close(); } catch {} _evtSource = null; }
1490
+ if (liveEl) liveEl.style.display = 'none';
1491
+
1492
+ // Pre-load current tail via JSON (no SSE until section is active).
1493
+ fetch('/api/extensions/events?limit=200')
1494
+ .then(function(r) { return r.json(); })
1495
+ .then(function(arr) {
1496
+ if (!Array.isArray(arr)) return;
1497
+ _evtRows = [];
1498
+ arr.forEach(_addEvtRow);
1499
+ _renderEvtRows();
1500
+ // Open SSE for live updates.
1501
+ try {
1502
+ _evtSource = new EventSource('/api/extensions/events?limit=1');
1503
+ _evtSource.onopen = function() { if (liveEl) liveEl.style.display = ''; };
1504
+ _evtSource.onmessage = function(e) {
1505
+ try {
1506
+ var obj = JSON.parse(e.data);
1507
+ if (obj && typeof obj === 'object' && !obj.showing) {
1508
+ _addEvtRow(obj);
1509
+ _renderEvtRows();
1510
+ }
1511
+ } catch {}
1512
+ };
1513
+ _evtSource.onerror = function() { if (liveEl) liveEl.style.display = 'none'; };
1514
+ } catch {}
1515
+ })
1516
+ .catch(function() {
1517
+ var el = document.getElementById('ext-events-content');
1518
+ if (!el) return;
1519
+ while (el.firstChild) el.removeChild(el.firstChild);
1520
+ var p = document.createElement('p');
1521
+ p.setAttribute('style', 'font-size:12px;color:var(--fg-dim);padding:8px 0');
1522
+ p.textContent = 'Permission events unavailable.';
1523
+ el.appendChild(p);
1524
+ });
1525
+
1526
+ // Wire filter controls.
1527
+ var filterExt = document.getElementById('ext-evt-filter-ext');
1528
+ var filterDenied = document.getElementById('ext-evt-filter-denied');
1529
+ var clearBtn = document.getElementById('ext-evt-clear');
1530
+ if (filterExt) filterExt.onchange = _renderEvtRows;
1531
+ if (filterDenied) filterDenied.onchange = _renderEvtRows;
1532
+ if (clearBtn) clearBtn.onclick = function() {
1533
+ _evtRows = [];
1534
+ _renderEvtRows();
1535
+ };
1536
+ }
1537
+
1306
1538
  // ====== RUN ALL LOADERS ======
1307
1539
  document.addEventListener('DOMContentLoaded', function() {
1308
1540
  loadCostToday();
@@ -1317,7 +1549,185 @@ document.addEventListener('DOMContentLoaded', function() {
1317
1549
  loadTrendSparkline();
1318
1550
  loadBlockUsage();
1319
1551
  loadExtensions();
1552
+ loadExtensionActive();
1553
+ loadExtensionEvents();
1554
+ loadExtensionCharts();
1320
1555
  });
1556
+
1557
+ // ====== AUDIT CHARTS (B19) ======
1558
+ // Inlined to keep dashboard a single self-contained HTML file.
1559
+
1560
+ function _ijfwChartTheme(el) {
1561
+ var fg = '#9ad2ff', bg = 'rgba(154,210,255,0.18)', warn = '#ff9b3a', txt = '#cfd6dd';
1562
+ try {
1563
+ var cs = getComputedStyle(el);
1564
+ fg = (cs.getPropertyValue('--ijfw-chart-fg') || '').trim() || fg;
1565
+ bg = (cs.getPropertyValue('--ijfw-chart-bg') || '').trim() || bg;
1566
+ warn = (cs.getPropertyValue('--ijfw-chart-warning') || '').trim() || warn;
1567
+ txt = (cs.getPropertyValue('--ijfw-chart-text') || '').trim() || txt;
1568
+ } catch (e) {}
1569
+ return { fg: fg, bg: bg, warn: warn, text: txt };
1570
+ }
1571
+
1572
+ function _ijfwLineChart(canvas, points) {
1573
+ if (!canvas || !canvas.getContext) return;
1574
+ var ctx = canvas.getContext('2d');
1575
+ var W = canvas.width, H = canvas.height;
1576
+ var theme = _ijfwChartTheme(canvas);
1577
+ ctx.clearRect(0, 0, W, H);
1578
+ if (!points || !points.length) return;
1579
+ var xs = points.map(function(p){ return p.x; });
1580
+ var ys = points.map(function(p){ return Math.max(0, p.y); });
1581
+ var xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
1582
+ if (xMax === xMin) xMax = xMin + 1;
1583
+ var yMax = Math.max(1, Math.max.apply(null, ys));
1584
+ var pad = 4, innerW = W - pad*2, innerH = H - pad*2;
1585
+ function px(x){ return pad + ((x - xMin)/(xMax - xMin)) * innerW; }
1586
+ function py(y){ return pad + (1 - (y / yMax)) * innerH; }
1587
+ ctx.beginPath(); ctx.moveTo(px(points[0].x), H - pad);
1588
+ for (var i = 0; i < points.length; i++) ctx.lineTo(px(points[i].x), py(points[i].y));
1589
+ ctx.lineTo(px(points[points.length-1].x), H - pad);
1590
+ ctx.closePath(); ctx.fillStyle = theme.bg; ctx.fill();
1591
+ ctx.beginPath(); ctx.moveTo(px(points[0].x), py(points[0].y));
1592
+ for (var j = 1; j < points.length; j++) ctx.lineTo(px(points[j].x), py(points[j].y));
1593
+ ctx.strokeStyle = theme.fg; ctx.lineWidth = 1.5; ctx.stroke();
1594
+ }
1595
+
1596
+ function _ijfwBarChart(canvas, bars, horizontal) {
1597
+ if (!canvas || !canvas.getContext) return;
1598
+ var ctx = canvas.getContext('2d');
1599
+ var W = canvas.width, H = canvas.height;
1600
+ var theme = _ijfwChartTheme(canvas);
1601
+ ctx.clearRect(0, 0, W, H);
1602
+ if (!bars || !bars.length) return;
1603
+ var values = bars.map(function(b){ return Math.max(0, b.value || 0); });
1604
+ var maxVal = Math.max(1, Math.max.apply(null, values));
1605
+ var pad = 4;
1606
+ var labelGutter = horizontal ? 80 : 14;
1607
+ var innerW = W - pad*2 - (horizontal ? labelGutter : 0);
1608
+ var innerH = H - pad*2 - (horizontal ? 0 : labelGutter);
1609
+ var slot = (horizontal ? innerH : innerW) / bars.length;
1610
+ var barW = Math.max(1, slot * 0.7);
1611
+ for (var i = 0; i < bars.length; i++) {
1612
+ var v = values[i];
1613
+ var color = bars[i].color || theme.fg;
1614
+ if (horizontal) {
1615
+ var y = pad + i*slot + (slot-barW)/2;
1616
+ var len = (v/maxVal) * innerW;
1617
+ ctx.fillStyle = theme.bg; ctx.fillRect(pad + labelGutter, y, innerW, barW);
1618
+ ctx.fillStyle = color; ctx.fillRect(pad + labelGutter, y, len, barW);
1619
+ ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || ''), pad, y + barW*0.75);
1620
+ } else {
1621
+ var x = pad + i*slot + (slot-barW)/2;
1622
+ var len2 = (v/maxVal) * innerH;
1623
+ ctx.fillStyle = theme.bg; ctx.fillRect(x, pad, barW, innerH);
1624
+ ctx.fillStyle = color; ctx.fillRect(x, pad + (innerH - len2), barW, len2);
1625
+ ctx.fillStyle = theme.text; ctx.fillText(String(bars[i].label || '').slice(0, 8), x, H - pad);
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ function _ijfwProgressBar(parent, data) {
1631
+ if (!parent) return;
1632
+ var theme = _ijfwChartTheme(parent);
1633
+ var cur = Math.max(0, data.current || 0);
1634
+ var lim = (data.limit === null || data.limit === undefined) ? null : data.limit;
1635
+ var label = data.label || '';
1636
+ var warn = Boolean(data.warning);
1637
+ var row = document.createElement('div');
1638
+ row.className = 'ijfw-progress' + (warn ? ' ijfw-progress--warn' : '');
1639
+ row.setAttribute('style', 'display:flex;align-items:center;gap:6px;font-size:11px');
1640
+ var lbl = document.createElement('span'); lbl.textContent = label; lbl.setAttribute('style','min-width:88px;color:var(--fg-dim)');
1641
+ row.appendChild(lbl);
1642
+ if (lim === null) {
1643
+ var val = document.createElement('span'); val.textContent = cur + ' / unlimited';
1644
+ val.setAttribute('style','color:var(--fg-dim)');
1645
+ row.appendChild(val);
1646
+ } else {
1647
+ var rail = document.createElement('span');
1648
+ rail.setAttribute('style', 'background:' + theme.bg + ';display:inline-block;height:6px;width:120px;border-radius:3px;overflow:hidden;vertical-align:middle');
1649
+ var fill = document.createElement('span');
1650
+ var pct = lim > 0 ? Math.min(100, (cur/lim)*100) : 0;
1651
+ fill.setAttribute('style', 'display:block;height:100%;width:' + pct.toFixed(1) + '%;background:' + (warn ? theme.warn : theme.fg));
1652
+ rail.appendChild(fill);
1653
+ row.appendChild(rail);
1654
+ var val2 = document.createElement('span'); val2.textContent = cur + ' / ' + lim;
1655
+ row.appendChild(val2);
1656
+ }
1657
+ parent.appendChild(row);
1658
+ }
1659
+
1660
+ function loadExtensionCharts() {
1661
+ // Hourly line chart.
1662
+ fetch('/api/extensions/aggregates?window=24h&kind=hourly')
1663
+ .then(function(r){ return r.json(); })
1664
+ .then(function(o){
1665
+ var canvas = document.getElementById('ext-chart-hourly');
1666
+ if (!canvas) return;
1667
+ var buckets = (o && o.buckets) || [];
1668
+ var points = buckets.map(function(b, i){ return { x: i, y: b.count }; });
1669
+ _ijfwLineChart(canvas, points);
1670
+ }).catch(function(){});
1671
+
1672
+ // Deny rate per extension (horizontal bar).
1673
+ fetch('/api/extensions/aggregates?window=24h&kind=by_ext')
1674
+ .then(function(r){ return r.json(); })
1675
+ .then(function(o){
1676
+ var canvas = document.getElementById('ext-chart-byext');
1677
+ if (!canvas) return;
1678
+ var rows = (o && o.rows) || [];
1679
+ var bars = rows.slice(0, 8).map(function(r){
1680
+ var total = (r.allowed||0) + (r.denied||0);
1681
+ return { label: r.ext, value: total > 0 ? (r.denied/total) * 100 : 0 };
1682
+ });
1683
+ _ijfwBarChart(canvas, bars, true);
1684
+ }).catch(function(){});
1685
+
1686
+ // Top denied tools.
1687
+ fetch('/api/extensions/aggregates?window=24h&kind=by_tool')
1688
+ .then(function(r){ return r.json(); })
1689
+ .then(function(o){
1690
+ var canvas = document.getElementById('ext-chart-bytool');
1691
+ if (!canvas) return;
1692
+ var rows = (o && o.rows) || [];
1693
+ var bars = rows.map(function(r){ return { label: r.tool, value: r.count }; });
1694
+ _ijfwBarChart(canvas, bars, true);
1695
+ }).catch(function(){});
1696
+
1697
+ // Quotas: per-extension progress bars + bash-bypass warning chip.
1698
+ fetch('/api/extensions/aggregates?window=24h&kind=quotas')
1699
+ .then(function(r){ return r.json(); })
1700
+ .then(function(o){
1701
+ var parent = document.getElementById('ext-chart-quotas');
1702
+ if (!parent) return;
1703
+ while (parent.firstChild) parent.removeChild(parent.firstChild);
1704
+ var rows = (o && o.rows) || [];
1705
+ if (rows.length === 0) {
1706
+ var empty = document.createElement('div');
1707
+ empty.setAttribute('style','color:var(--fg-dim);font-size:12px');
1708
+ empty.textContent = 'No active extension.';
1709
+ parent.appendChild(empty);
1710
+ return;
1711
+ }
1712
+ rows.forEach(function(row){
1713
+ var header = document.createElement('div');
1714
+ header.setAttribute('style','font-weight:600;font-size:12px;margin-top:4px');
1715
+ header.textContent = row.ext_name + ' (' + (row.scope || 'user') + ')';
1716
+ parent.appendChild(header);
1717
+ if (row.warn_bash_bypass) {
1718
+ var chip = document.createElement('div');
1719
+ chip.className = 'ijfw-warn-chip';
1720
+ chip.setAttribute('style','background:rgba(255,155,58,0.12);color:var(--ijfw-chart-warning,#ff9b3a);font-size:11px;padding:4px 8px;border-radius:4px;margin:4px 0');
1721
+ chip.textContent = '⚠ This extension has tool:bash and a strict files_written quota. Bash content bypasses per-file accounting.';
1722
+ parent.appendChild(chip);
1723
+ }
1724
+ var dims = row.dimensions || {};
1725
+ _ijfwProgressBar(parent, { current: (dims.files_written||{}).current||0, limit: (dims.files_written||{}).limit, label: 'files_written', warning: row.warn_bash_bypass });
1726
+ _ijfwProgressBar(parent, { current: (dims.bytes_written||{}).current||0, limit: (dims.bytes_written||{}).limit, label: 'bytes_written', warning: row.warn_bash_bypass });
1727
+ _ijfwProgressBar(parent, { current: (dims.wall_clock_ms||{}).current||0, limit: (dims.wall_clock_ms||{}).limit, label: 'wall_clock_ms' });
1728
+ });
1729
+ }).catch(function(){});
1730
+ }
1321
1731
  </script>
1322
1732
  </body>
1323
1733
  </html>