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.
- package/README.md +99 -5
- package/package.json +15 -3
- package/src/cli/index.js +314 -1
- package/src/core/auth.js +341 -0
- package/src/core/compliance.js +1 -1
- package/src/core/crypto.js +158 -0
- package/src/core/engine.js +62 -0
- package/src/core/policy.js +719 -0
- package/src/core/sso.js +386 -0
- package/src/core/storage.js +23 -4
- package/src/core/telemetry.js +281 -0
- package/src/dashboard/index.html +338 -0
- package/src/mcp/http-server.js +248 -3
- package/src/mcp/server.js +172 -1
|
@@ -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 — 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 — Developed by Sandeep Roy — <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>
|