speclock 2.5.0 → 3.5.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,281 @@
1
+ /**
2
+ * SpecLock Telemetry & Analytics (v3.5)
3
+ * Opt-in anonymous usage analytics for product improvement.
4
+ *
5
+ * DISABLED by default. Enable via SPECLOCK_TELEMETRY=true env var.
6
+ * NEVER tracks: lock content, project names, file paths, PII.
7
+ * ONLY tracks: tool usage counts, conflict rates, response times, feature adoption.
8
+ *
9
+ * Data stored locally in .speclock/telemetry.json.
10
+ * Optional remote endpoint via SPECLOCK_TELEMETRY_ENDPOINT env var.
11
+ *
12
+ * Developed by Sandeep Roy (https://github.com/sgroy10)
13
+ */
14
+
15
+ import fs from "fs";
16
+ import path from "path";
17
+
18
+ const TELEMETRY_FILE = "telemetry.json";
19
+ const FLUSH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
20
+ const MAX_EVENTS_BUFFER = 500;
21
+
22
+ // --- Telemetry state ---
23
+
24
+ let _enabled = null;
25
+ let _buffer = [];
26
+ let _flushTimer = null;
27
+
28
+ /**
29
+ * Check if telemetry is enabled (opt-in only)
30
+ */
31
+ export function isTelemetryEnabled() {
32
+ if (_enabled !== null) return _enabled;
33
+ _enabled = process.env.SPECLOCK_TELEMETRY === "true";
34
+ return _enabled;
35
+ }
36
+
37
+ /**
38
+ * Reset telemetry state (for testing)
39
+ */
40
+ export function resetTelemetry() {
41
+ _enabled = null;
42
+ _buffer = [];
43
+ if (_flushTimer) {
44
+ clearInterval(_flushTimer);
45
+ _flushTimer = null;
46
+ }
47
+ }
48
+
49
+ // --- Local telemetry store ---
50
+
51
+ function telemetryPath(root) {
52
+ return path.join(root, ".speclock", TELEMETRY_FILE);
53
+ }
54
+
55
+ function readTelemetryStore(root) {
56
+ const p = telemetryPath(root);
57
+ if (!fs.existsSync(p)) {
58
+ return createEmptyStore();
59
+ }
60
+ try {
61
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
62
+ } catch {
63
+ return createEmptyStore();
64
+ }
65
+ }
66
+
67
+ function writeTelemetryStore(root, store) {
68
+ const p = telemetryPath(root);
69
+ fs.writeFileSync(p, JSON.stringify(store, null, 2));
70
+ }
71
+
72
+ function createEmptyStore() {
73
+ return {
74
+ version: "1.0",
75
+ instanceId: generateInstanceId(),
76
+ createdAt: new Date().toISOString(),
77
+ updatedAt: new Date().toISOString(),
78
+ toolUsage: {},
79
+ conflicts: { total: 0, blocked: 0, advisory: 0 },
80
+ features: {},
81
+ sessions: { total: 0, tools: {} },
82
+ responseTimes: { samples: [], avgMs: 0 },
83
+ daily: {},
84
+ };
85
+ }
86
+
87
+ function generateInstanceId() {
88
+ // Anonymous instance ID — no PII, just random hex
89
+ const bytes = new Uint8Array(8);
90
+ for (let i = 0; i < 8; i++) bytes[i] = Math.floor(Math.random() * 256);
91
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+
94
+ // --- Tracking functions ---
95
+
96
+ /**
97
+ * Track a tool invocation
98
+ */
99
+ export function trackToolUsage(root, toolName, durationMs) {
100
+ if (!isTelemetryEnabled()) return;
101
+
102
+ const store = readTelemetryStore(root);
103
+
104
+ // Tool usage count
105
+ if (!store.toolUsage[toolName]) {
106
+ store.toolUsage[toolName] = { count: 0, totalMs: 0, avgMs: 0 };
107
+ }
108
+ store.toolUsage[toolName].count++;
109
+ store.toolUsage[toolName].totalMs += (durationMs || 0);
110
+ store.toolUsage[toolName].avgMs = Math.round(
111
+ store.toolUsage[toolName].totalMs / store.toolUsage[toolName].count
112
+ );
113
+
114
+ // Response time sampling (keep last 100)
115
+ if (durationMs) {
116
+ store.responseTimes.samples.push(durationMs);
117
+ if (store.responseTimes.samples.length > 100) {
118
+ store.responseTimes.samples = store.responseTimes.samples.slice(-100);
119
+ }
120
+ store.responseTimes.avgMs = Math.round(
121
+ store.responseTimes.samples.reduce((a, b) => a + b, 0) / store.responseTimes.samples.length
122
+ );
123
+ }
124
+
125
+ // Daily counter
126
+ const today = new Date().toISOString().slice(0, 10);
127
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
128
+ store.daily[today].calls++;
129
+
130
+ // Trim daily entries older than 30 days
131
+ const cutoff = new Date();
132
+ cutoff.setDate(cutoff.getDate() - 30);
133
+ const cutoffStr = cutoff.toISOString().slice(0, 10);
134
+ for (const key of Object.keys(store.daily)) {
135
+ if (key < cutoffStr) delete store.daily[key];
136
+ }
137
+
138
+ store.updatedAt = new Date().toISOString();
139
+ writeTelemetryStore(root, store);
140
+ }
141
+
142
+ /**
143
+ * Track a conflict check result
144
+ */
145
+ export function trackConflict(root, hasConflict, blocked) {
146
+ if (!isTelemetryEnabled()) return;
147
+
148
+ const store = readTelemetryStore(root);
149
+ store.conflicts.total++;
150
+ if (blocked) {
151
+ store.conflicts.blocked++;
152
+ } else if (hasConflict) {
153
+ store.conflicts.advisory++;
154
+ }
155
+
156
+ const today = new Date().toISOString().slice(0, 10);
157
+ if (!store.daily[today]) store.daily[today] = { calls: 0, conflicts: 0 };
158
+ if (hasConflict) store.daily[today].conflicts++;
159
+
160
+ store.updatedAt = new Date().toISOString();
161
+ writeTelemetryStore(root, store);
162
+ }
163
+
164
+ /**
165
+ * Track feature adoption (which features are being used)
166
+ */
167
+ export function trackFeature(root, featureName) {
168
+ if (!isTelemetryEnabled()) return;
169
+
170
+ const store = readTelemetryStore(root);
171
+ if (!store.features[featureName]) {
172
+ store.features[featureName] = { firstUsed: new Date().toISOString(), count: 0 };
173
+ }
174
+ store.features[featureName].count++;
175
+ store.features[featureName].lastUsed = new Date().toISOString();
176
+
177
+ store.updatedAt = new Date().toISOString();
178
+ writeTelemetryStore(root, store);
179
+ }
180
+
181
+ /**
182
+ * Track session start
183
+ */
184
+ export function trackSession(root, toolName) {
185
+ if (!isTelemetryEnabled()) return;
186
+
187
+ const store = readTelemetryStore(root);
188
+ store.sessions.total++;
189
+ if (!store.sessions.tools[toolName]) store.sessions.tools[toolName] = 0;
190
+ store.sessions.tools[toolName]++;
191
+
192
+ store.updatedAt = new Date().toISOString();
193
+ writeTelemetryStore(root, store);
194
+ }
195
+
196
+ // --- Analytics / Reporting ---
197
+
198
+ /**
199
+ * Get telemetry summary for dashboard display
200
+ */
201
+ export function getTelemetrySummary(root) {
202
+ if (!isTelemetryEnabled()) {
203
+ return { enabled: false, message: "Telemetry is disabled. Set SPECLOCK_TELEMETRY=true to enable." };
204
+ }
205
+
206
+ const store = readTelemetryStore(root);
207
+
208
+ // Top tools by usage
209
+ const topTools = Object.entries(store.toolUsage)
210
+ .sort(([, a], [, b]) => b.count - a.count)
211
+ .slice(0, 10)
212
+ .map(([name, data]) => ({ name, ...data }));
213
+
214
+ // Daily trend (last 7 days)
215
+ const days = [];
216
+ for (let i = 6; i >= 0; i--) {
217
+ const d = new Date();
218
+ d.setDate(d.getDate() - i);
219
+ const key = d.toISOString().slice(0, 10);
220
+ days.push({ date: key, ...(store.daily[key] || { calls: 0, conflicts: 0 }) });
221
+ }
222
+
223
+ // Feature adoption
224
+ const features = Object.entries(store.features)
225
+ .sort(([, a], [, b]) => b.count - a.count)
226
+ .map(([name, data]) => ({ name, ...data }));
227
+
228
+ return {
229
+ enabled: true,
230
+ instanceId: store.instanceId,
231
+ updatedAt: store.updatedAt,
232
+ totalCalls: Object.values(store.toolUsage).reduce((sum, t) => sum + t.count, 0),
233
+ avgResponseMs: store.responseTimes.avgMs,
234
+ conflicts: store.conflicts,
235
+ sessions: store.sessions,
236
+ topTools,
237
+ dailyTrend: days,
238
+ features,
239
+ };
240
+ }
241
+
242
+ // --- Remote telemetry (optional) ---
243
+
244
+ /**
245
+ * Flush telemetry to remote endpoint if configured.
246
+ * Only sends anonymized aggregate data — never lock content or PII.
247
+ */
248
+ export async function flushToRemote(root) {
249
+ if (!isTelemetryEnabled()) return { sent: false, reason: "disabled" };
250
+
251
+ const endpoint = process.env.SPECLOCK_TELEMETRY_ENDPOINT;
252
+ if (!endpoint) return { sent: false, reason: "no endpoint configured" };
253
+
254
+ const summary = getTelemetrySummary(root);
255
+ if (!summary.enabled) return { sent: false, reason: "disabled" };
256
+
257
+ // Build anonymized payload
258
+ const payload = {
259
+ instanceId: summary.instanceId,
260
+ version: "3.5.0",
261
+ totalCalls: summary.totalCalls,
262
+ avgResponseMs: summary.avgResponseMs,
263
+ conflicts: summary.conflicts,
264
+ sessions: summary.sessions,
265
+ topTools: summary.topTools.map(t => ({ name: t.name, count: t.count })),
266
+ features: summary.features.map(f => ({ name: f.name, count: f.count })),
267
+ timestamp: new Date().toISOString(),
268
+ };
269
+
270
+ try {
271
+ const response = await fetch(endpoint, {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify(payload),
275
+ signal: AbortSignal.timeout(5000),
276
+ });
277
+ return { sent: true, status: response.status };
278
+ } catch {
279
+ return { sent: false, reason: "network error" };
280
+ }
281
+ }
@@ -0,0 +1,338 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SpecLock Dashboard</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
10
+ --text: #e2e8f0; --muted: #94a3b8; --accent: #3b82f6;
11
+ --green: #22c55e; --red: #ef4444; --yellow: #eab308; --purple: #a855f7;
12
+ }
13
+ * { box-sizing: border-box; margin: 0; padding: 0; }
14
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
15
+
16
+ /* Header */
17
+ .header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; background: var(--surface); border-bottom: 1px solid var(--border); }
18
+ .header h1 { font-size: 20px; font-weight: 700; }
19
+ .header h1 span { color: var(--accent); }
20
+ .header .meta { font-size: 13px; color: var(--muted); }
21
+ .status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
22
+ .status-badge.healthy { background: rgba(34,197,94,0.15); color: var(--green); }
23
+ .status-badge.warning { background: rgba(234,179,8,0.15); color: var(--yellow); }
24
+
25
+ /* Grid */
26
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; padding: 24px; }
27
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
28
+ .card h2 { font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
29
+ .card .big-number { font-size: 36px; font-weight: 700; line-height: 1; }
30
+ .card .sub { font-size: 13px; color: var(--muted); margin-top: 4px; }
31
+
32
+ /* Stats row */
33
+ .stats-row { display: flex; gap: 12px; margin-top: 12px; }
34
+ .stat { flex: 1; text-align: center; padding: 8px; background: rgba(255,255,255,0.03); border-radius: 8px; }
35
+ .stat .val { font-size: 20px; font-weight: 700; }
36
+ .stat .label { font-size: 11px; color: var(--muted); margin-top: 2px; }
37
+
38
+ /* Table */
39
+ .table-card { grid-column: span 2; }
40
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
41
+ th { text-align: left; padding: 8px 12px; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); }
42
+ td { padding: 8px 12px; border-bottom: 1px solid rgba(51,65,85,0.5); }
43
+ tr:hover td { background: rgba(255,255,255,0.02); }
44
+
45
+ /* Badge */
46
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
47
+ .badge.high { background: rgba(239,68,68,0.2); color: var(--red); }
48
+ .badge.medium { background: rgba(234,179,8,0.2); color: var(--yellow); }
49
+ .badge.low { background: rgba(34,197,94,0.2); color: var(--green); }
50
+ .badge.active { background: rgba(59,130,246,0.2); color: var(--accent); }
51
+ .badge.inactive { background: rgba(148,163,184,0.2); color: var(--muted); }
52
+
53
+ /* Chart bar */
54
+ .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-top: 12px; }
55
+ .bar { flex: 1; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; position: relative; transition: height 0.3s; }
56
+ .bar:hover { opacity: 0.8; }
57
+ .bar .tip { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: var(--muted); white-space: nowrap; display: none; }
58
+ .bar:hover .tip { display: block; }
59
+ .bar-labels { display: flex; gap: 4px; margin-top: 4px; }
60
+ .bar-labels span { flex: 1; text-align: center; font-size: 10px; color: var(--muted); }
61
+
62
+ /* Sections */
63
+ .full-width { grid-column: 1 / -1; }
64
+ .section-title { padding: 24px 24px 0; font-size: 16px; font-weight: 700; color: var(--muted); }
65
+
66
+ /* Loading */
67
+ .loading { text-align: center; padding: 40px; color: var(--muted); }
68
+ .error { color: var(--red); text-align: center; padding: 20px; }
69
+
70
+ /* Refresh button */
71
+ .refresh-btn { background: var(--accent); color: white; border: none; padding: 6px 14px; border-radius: 6px; font-size: 12px; cursor: pointer; }
72
+ .refresh-btn:hover { opacity: 0.9; }
73
+
74
+ /* Timeline */
75
+ .timeline { list-style: none; }
76
+ .timeline li { padding: 8px 0; border-bottom: 1px solid rgba(51,65,85,0.3); display: flex; gap: 12px; align-items: flex-start; }
77
+ .timeline .time { font-size: 11px; color: var(--muted); min-width: 130px; }
78
+ .timeline .event-type { font-size: 11px; font-weight: 600; min-width: 100px; }
79
+ .timeline .event-summary { font-size: 13px; }
80
+
81
+ @media (max-width: 768px) {
82
+ .grid { grid-template-columns: 1fr; }
83
+ .table-card { grid-column: span 1; }
84
+ }
85
+ </style>
86
+ </head>
87
+ <body>
88
+
89
+ <div class="header">
90
+ <div>
91
+ <h1><span>SpecLock</span> Dashboard</h1>
92
+ <div class="meta">v3.5.0 &mdash; AI Constraint Engine</div>
93
+ </div>
94
+ <div style="display:flex;align-items:center;gap:12px;">
95
+ <span id="health-badge" class="status-badge healthy">Loading...</span>
96
+ <button class="refresh-btn" onclick="loadAll()">Refresh</button>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Overview Cards -->
101
+ <div class="grid">
102
+ <div class="card">
103
+ <h2>Active Locks</h2>
104
+ <div class="big-number" id="lock-count">-</div>
105
+ <div class="sub" id="lock-sub"></div>
106
+ </div>
107
+ <div class="card">
108
+ <h2>Decisions</h2>
109
+ <div class="big-number" id="decision-count">-</div>
110
+ </div>
111
+ <div class="card">
112
+ <h2>Events</h2>
113
+ <div class="big-number" id="event-count">-</div>
114
+ <div class="sub" id="event-sub"></div>
115
+ </div>
116
+ <div class="card">
117
+ <h2>Violations Blocked</h2>
118
+ <div class="big-number" id="violation-count">-</div>
119
+ <div class="stats-row">
120
+ <div class="stat"><div class="val" id="v-blocked" style="color:var(--red)">-</div><div class="label">Blocked</div></div>
121
+ <div class="stat"><div class="val" id="v-advisory" style="color:var(--yellow)">-</div><div class="label">Advisory</div></div>
122
+ <div class="stat"><div class="val" id="v-overrides" style="color:var(--purple)">-</div><div class="label">Overrides</div></div>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Enforcement & Security -->
128
+ <div class="section-title">Enforcement & Security</div>
129
+ <div class="grid">
130
+ <div class="card">
131
+ <h2>Enforcement Mode</h2>
132
+ <div class="big-number" id="enforce-mode" style="text-transform:uppercase">-</div>
133
+ <div class="sub" id="enforce-threshold"></div>
134
+ </div>
135
+ <div class="card">
136
+ <h2>Authentication</h2>
137
+ <div class="big-number" id="auth-status">-</div>
138
+ <div class="sub" id="auth-keys"></div>
139
+ </div>
140
+ <div class="card">
141
+ <h2>Encryption</h2>
142
+ <div class="big-number" id="encrypt-status">-</div>
143
+ <div class="sub" id="encrypt-algo"></div>
144
+ </div>
145
+ <div class="card">
146
+ <h2>Audit Chain</h2>
147
+ <div class="big-number" id="audit-status">-</div>
148
+ <div class="sub" id="audit-events"></div>
149
+ </div>
150
+ </div>
151
+
152
+ <!-- Active Locks Table -->
153
+ <div class="section-title">Active Locks</div>
154
+ <div class="grid">
155
+ <div class="card table-card">
156
+ <table>
157
+ <thead><tr><th>ID</th><th>Constraint</th><th>Source</th><th>Tags</th><th>Created</th></tr></thead>
158
+ <tbody id="locks-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
159
+ </table>
160
+ </div>
161
+ </div>
162
+
163
+ <!-- Recent Events Timeline -->
164
+ <div class="section-title">Recent Events</div>
165
+ <div class="grid">
166
+ <div class="card full-width">
167
+ <ul class="timeline" id="events-timeline">
168
+ <li class="loading">Loading...</li>
169
+ </ul>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Sessions -->
174
+ <div class="section-title">Session History</div>
175
+ <div class="grid">
176
+ <div class="card table-card">
177
+ <table>
178
+ <thead><tr><th>Tool</th><th>Started</th><th>Duration</th><th>Events</th><th>Summary</th></tr></thead>
179
+ <tbody id="sessions-table"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
180
+ </table>
181
+ </div>
182
+ </div>
183
+
184
+ <div style="text-align:center;padding:24px;color:var(--muted);font-size:12px;">
185
+ SpecLock v3.5.0 &mdash; Developed by Sandeep Roy &mdash; <a href="https://github.com/sgroy10/speclock" style="color:var(--accent)">GitHub</a>
186
+ </div>
187
+
188
+ <script>
189
+ const API_BASE = window.location.origin;
190
+
191
+ async function apiFetch(path) {
192
+ try {
193
+ const res = await fetch(`${API_BASE}${path}`);
194
+ if (!res.ok) throw new Error(`${res.status}`);
195
+ return await res.json();
196
+ } catch (e) {
197
+ console.error(`API error ${path}:`, e);
198
+ return null;
199
+ }
200
+ }
201
+
202
+ async function loadAll() {
203
+ loadHealth();
204
+ loadContext();
205
+ loadEvents();
206
+ }
207
+
208
+ async function loadHealth() {
209
+ const h = await apiFetch('/health');
210
+ const badge = document.getElementById('health-badge');
211
+ if (h) {
212
+ badge.textContent = h.status === 'healthy' ? 'Healthy' : 'Warning';
213
+ badge.className = `status-badge ${h.status === 'healthy' ? 'healthy' : 'warning'}`;
214
+ document.getElementById('audit-status').textContent = h.auditChain === 'valid' ? 'Valid' : h.auditChain;
215
+ document.getElementById('audit-status').style.color = h.auditChain === 'valid' ? 'var(--green)' : 'var(--red)';
216
+ document.getElementById('auth-status').textContent = h.authEnabled ? 'Enabled' : 'Disabled';
217
+ document.getElementById('auth-status').style.color = h.authEnabled ? 'var(--green)' : 'var(--muted)';
218
+ } else {
219
+ badge.textContent = 'Error';
220
+ badge.className = 'status-badge warning';
221
+ }
222
+ }
223
+
224
+ async function loadContext() {
225
+ // Use a direct brain fetch via the dashboard API
226
+ const data = await apiFetch('/dashboard/api/brain');
227
+ if (!data) return;
228
+
229
+ const brain = data;
230
+ const activeLocks = (brain.specLock?.items || []).filter(l => l.active !== false);
231
+
232
+ // Overview
233
+ document.getElementById('lock-count').textContent = activeLocks.length;
234
+ document.getElementById('lock-sub').textContent = `${brain.specLock?.items?.length || 0} total (${activeLocks.length} active)`;
235
+ document.getElementById('decision-count').textContent = brain.decisions?.length || 0;
236
+ document.getElementById('event-count').textContent = brain.events?.count || 0;
237
+ document.getElementById('event-sub').textContent = `Last: ${brain.events?.lastEventId || 'none'}`;
238
+
239
+ // Violations
240
+ const violations = brain.state?.violations || [];
241
+ document.getElementById('violation-count').textContent = violations.length;
242
+ document.getElementById('v-blocked').textContent = violations.filter(v => v.blocked).length;
243
+ document.getElementById('v-advisory').textContent = violations.filter(v => !v.blocked).length;
244
+
245
+ // Overrides count
246
+ let overrideCount = 0;
247
+ for (const lock of (brain.specLock?.items || [])) {
248
+ overrideCount += (lock.overrides || []).length;
249
+ }
250
+ document.getElementById('v-overrides').textContent = overrideCount;
251
+
252
+ // Enforcement
253
+ const enforcement = brain.enforcement || { mode: 'advisory', blockThreshold: 70 };
254
+ document.getElementById('enforce-mode').textContent = enforcement.mode;
255
+ document.getElementById('enforce-mode').style.color = enforcement.mode === 'hard' ? 'var(--red)' : 'var(--yellow)';
256
+ document.getElementById('enforce-threshold').textContent = `Block threshold: ${enforcement.blockThreshold}%`;
257
+
258
+ // Encryption
259
+ document.getElementById('encrypt-status').textContent = data._encryption ? 'Enabled' : 'Disabled';
260
+ document.getElementById('encrypt-status').style.color = data._encryption ? 'var(--green)' : 'var(--muted)';
261
+ document.getElementById('encrypt-algo').textContent = data._encryption ? 'AES-256-GCM' : 'Set SPECLOCK_ENCRYPTION_KEY to enable';
262
+
263
+ // Auth keys
264
+ document.getElementById('auth-keys').textContent = data._authKeys ? `${data._authKeys} active key(s)` : '';
265
+
266
+ // Locks table
267
+ const locksBody = document.getElementById('locks-table');
268
+ if (activeLocks.length === 0) {
269
+ locksBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No active locks</td></tr>';
270
+ } else {
271
+ locksBody.innerHTML = activeLocks.map(l => `
272
+ <tr>
273
+ <td><code>${l.id}</code></td>
274
+ <td>${escHtml(l.text)}</td>
275
+ <td><span class="badge active">${l.source || 'agent'}</span></td>
276
+ <td>${(l.tags || []).map(t => `<span class="badge low">${t}</span>`).join(' ') || '-'}</td>
277
+ <td>${(l.createdAt || '').substring(0, 16)}</td>
278
+ </tr>
279
+ `).join('');
280
+ }
281
+
282
+ // Sessions table
283
+ const sessions = brain.sessions?.history || [];
284
+ const sessBody = document.getElementById('sessions-table');
285
+ if (sessions.length === 0) {
286
+ sessBody.innerHTML = '<tr><td colspan="5" style="color:var(--muted)">No sessions yet</td></tr>';
287
+ } else {
288
+ sessBody.innerHTML = sessions.slice(0, 20).map(s => {
289
+ const dur = s.startedAt && s.endedAt ? Math.round((new Date(s.endedAt) - new Date(s.startedAt)) / 60000) + ' min' : '-';
290
+ return `
291
+ <tr>
292
+ <td><span class="badge active">${s.toolUsed || 'unknown'}</span></td>
293
+ <td>${(s.startedAt || '').substring(0, 16)}</td>
294
+ <td>${dur}</td>
295
+ <td>${s.eventsInSession || 0}</td>
296
+ <td>${escHtml((s.summary || '').substring(0, 80))}</td>
297
+ </tr>
298
+ `;
299
+ }).join('');
300
+ }
301
+ }
302
+
303
+ async function loadEvents() {
304
+ const data = await apiFetch('/dashboard/api/events');
305
+ const timeline = document.getElementById('events-timeline');
306
+ if (!data || !data.events || data.events.length === 0) {
307
+ timeline.innerHTML = '<li style="color:var(--muted)">No events yet</li>';
308
+ return;
309
+ }
310
+
311
+ timeline.innerHTML = data.events.slice(0, 30).map(e => `
312
+ <li>
313
+ <span class="time">${(e.at || '').substring(0, 19)}</span>
314
+ <span class="event-type badge ${getEventColor(e.type)}">${e.type}</span>
315
+ <span class="event-summary">${escHtml(e.summary || e.eventId || '')}</span>
316
+ </li>
317
+ `).join('');
318
+ }
319
+
320
+ function getEventColor(type) {
321
+ if (type?.includes('lock') || type?.includes('violation')) return 'high';
322
+ if (type?.includes('session') || type?.includes('decision')) return 'medium';
323
+ return 'low';
324
+ }
325
+
326
+ function escHtml(s) {
327
+ const d = document.createElement('div');
328
+ d.textContent = s || '';
329
+ return d.innerHTML;
330
+ }
331
+
332
+ // Auto-refresh every 30 seconds
333
+ loadAll();
334
+ setInterval(loadAll, 30000);
335
+ </script>
336
+
337
+ </body>
338
+ </html>