@ijfw/memory-server 1.4.1 → 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
+ }
@@ -593,6 +593,31 @@ tr:hover td{background:var(--surface)}
593
593
  </div>
594
594
  </div>
595
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
+
596
621
  <!-- Sub-section: Permission events -->
597
622
  <div class="card">
598
623
  <div class="ctitle" style="justify-content:space-between">
@@ -1526,7 +1551,183 @@ document.addEventListener('DOMContentLoaded', function() {
1526
1551
  loadExtensions();
1527
1552
  loadExtensionActive();
1528
1553
  loadExtensionEvents();
1554
+ loadExtensionCharts();
1529
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
+ }
1530
1731
  </script>
1531
1732
  </body>
1532
1733
  </html>
@@ -22,6 +22,8 @@ import { searchMemory } from './memory/search.js';
22
22
  import { buildRecallCounts, mergeRecallCounts, topRecalled } from './memory/recall-counter.js';
23
23
  import { PLACEHOLDER_HTML } from './design-companion.js';
24
24
  import { listExtensions } from './extension-installer.js';
25
+ import { aggregateEvents, computeWarnBashBypass, readActiveManifest } from './dashboard-aggregator.js';
26
+ import { getQuotaUsage } from './extension-quota-tracker.js';
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
29
  // REPO_ROOT: IJFW_PROJECT_ROOT override > user's interactive shell cwd (PWD) > process.cwd() fallback.
@@ -931,6 +933,111 @@ export async function startServer(options = {}) {
931
933
  res.end(JSON.stringify(events));
932
934
  }],
933
935
 
936
+ // ---------- extensions: aggregates (B19) ----------
937
+ // Server-side aggregation of permission-events.jsonl for the per-tool
938
+ // audit charts. Filters are strictly allowlisted.
939
+ // ?window=24h|30m|7d (regex: \d+[hmd])
940
+ // ?kind=hourly|by_ext|by_tool|quotas
941
+ ['/api/extensions/aggregates', async (req, res, url) => {
942
+ const ALLOWED_KINDS = new Set(['hourly', 'by_ext', 'by_tool', 'quotas']);
943
+ const WINDOW_RE = /^\d+(h|m|d)$/;
944
+ const ALLOWED_KEYS = new Set(['window', 'kind']);
945
+ try {
946
+ for (const key of url.searchParams.keys()) {
947
+ if (!ALLOWED_KEYS.has(key)) {
948
+ res.writeHead(400, { 'Content-Type': 'application/json' });
949
+ res.end(JSON.stringify({ error: `unknown filter parameter: ${key}` }));
950
+ return;
951
+ }
952
+ }
953
+ const kind = url.searchParams.get('kind') || 'hourly';
954
+ if (!ALLOWED_KINDS.has(kind)) {
955
+ res.writeHead(400, { 'Content-Type': 'application/json' });
956
+ res.end(JSON.stringify({ error: `invalid kind: ${kind}` }));
957
+ return;
958
+ }
959
+ const rawWindow = url.searchParams.get('window') || '24h';
960
+ if (!WINDOW_RE.test(rawWindow)) {
961
+ res.writeHead(400, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify({ error: `invalid window: ${rawWindow}` }));
963
+ return;
964
+ }
965
+ // Parse window into ms.
966
+ const num = parseInt(rawWindow.slice(0, -1), 10);
967
+ const unit = rawWindow.slice(-1);
968
+ const mult = unit === 'h' ? 3600_000 : unit === 'm' ? 60_000 : 86_400_000;
969
+ const windowMs = num * mult;
970
+
971
+ const home = homedir();
972
+ const eventsPath = join(home, '.ijfw', 'state', 'permission-events.jsonl');
973
+
974
+ if (kind === 'quotas') {
975
+ // Walk the active extension state and compute per-extension usage.
976
+ let active = null;
977
+ try {
978
+ const activePath = join(home, '.ijfw', 'state', 'active-extension.json');
979
+ if (existsSync(activePath)) {
980
+ active = JSON.parse(readFileSync(activePath, 'utf8'));
981
+ }
982
+ } catch { active = null; }
983
+ const rows = [];
984
+ if (active && typeof active === 'object') {
985
+ // active may be a single record or a map keyed by name.
986
+ const entries = Array.isArray(active.extensions)
987
+ ? active.extensions
988
+ : (active.name ? [active] : []);
989
+ for (const ent of entries) {
990
+ if (!ent || !ent.name) continue;
991
+ const scope = ent.scope || 'user';
992
+ const manifest = readActiveManifest({ scope, name: ent.name, home, projectRoot: REPO_ROOT });
993
+ const quotas = (manifest && manifest.quotas) || {};
994
+ const usage = await getQuotaUsage(ent.name, { homeDir: home, limits: quotas });
995
+ rows.push({
996
+ ...usage,
997
+ scope,
998
+ warn_bash_bypass: computeWarnBashBypass(manifest),
999
+ });
1000
+ }
1001
+ }
1002
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1003
+ res.end(JSON.stringify({ rows }));
1004
+ return;
1005
+ }
1006
+
1007
+ const agg = await aggregateEvents(eventsPath, { windowMs });
1008
+
1009
+ if (kind === 'hourly') {
1010
+ const buckets = Object.entries(agg.hourly)
1011
+ .map(([hour, count]) => ({ hour, count }))
1012
+ .sort((a, b) => a.hour.localeCompare(b.hour));
1013
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1014
+ res.end(JSON.stringify({ buckets }));
1015
+ return;
1016
+ }
1017
+
1018
+ if (kind === 'by_ext') {
1019
+ const rows = Object.entries(agg.by_extension)
1020
+ .map(([ext, v]) => ({ ext, allowed: v.allowed, denied: v.denied }))
1021
+ .sort((a, b) => b.denied - a.denied);
1022
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1023
+ res.end(JSON.stringify({ rows }));
1024
+ return;
1025
+ }
1026
+
1027
+ // kind === 'by_tool'
1028
+ const rows = Object.entries(agg.by_tool_denied)
1029
+ .map(([tool, count]) => ({ tool, count }))
1030
+ .sort((a, b) => b.count - a.count)
1031
+ .slice(0, 10);
1032
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1033
+ res.end(JSON.stringify({ rows }));
1034
+ } catch (err) {
1035
+ process.stderr.write(`[ijfw-mcp] /api/extensions/aggregates: ${err && err.message ? err.message : err}\n`);
1036
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1037
+ res.end(JSON.stringify({ buckets: [], rows: [], error: 'aggregation failed' }));
1038
+ }
1039
+ }],
1040
+
934
1041
  // ---------- extensions health (W3/t15) ----------
935
1042
  // Reads .ijfw/state/extension-registry.json (project) plus org/user via
936
1043
  // listExtensions(). Missing or malformed registry yields {extensions: []}