@online5880/opensession 0.1.0 → 0.1.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.
- package/README.ko.md +133 -0
- package/README.md +137 -30
- package/package.json +23 -20
- package/site/index.html +35 -0
- package/sql/schema.sql +38 -27
- package/src/automation.js +197 -0
- package/src/cli.js +1317 -172
- package/src/config.js +118 -32
- package/src/hook-server.js +194 -0
- package/src/idempotency.js +66 -0
- package/src/metrics.js +110 -0
- package/src/supabase.js +309 -119
- package/src/tui.js +159 -0
- package/src/viewer.js +708 -0
- package/test/cli-compatibility.test.js +63 -0
- package/test/config-secrets.test.js +47 -0
- package/test/idempotency.test.js +30 -0
- package/test/supabase-append-event.test.js +133 -0
package/src/viewer.js
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { getConfigPath, readConfig } from './config.js';
|
|
4
|
+
import { computeKpis, computeWeeklyTrend } from './metrics.js';
|
|
5
|
+
import { getClient, getSessionEvents, listProjects, listSessionEvents, listSessions } from './supabase.js';
|
|
6
|
+
|
|
7
|
+
const CSS = `
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #080b12;
|
|
10
|
+
--panel: #121a28;
|
|
11
|
+
--panel-light: #182234;
|
|
12
|
+
--text: #e9f1ff;
|
|
13
|
+
--muted: #99abc9;
|
|
14
|
+
--line: #22314a;
|
|
15
|
+
--brand: #64d6ff;
|
|
16
|
+
--accent: #8c7bff;
|
|
17
|
+
--success: #37d67a;
|
|
18
|
+
--error: #ff4d4d;
|
|
19
|
+
}
|
|
20
|
+
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
21
|
+
header { padding: 16px 20px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
|
22
|
+
header h1 { margin: 0; font-size: 18px; color: var(--brand); }
|
|
23
|
+
header p { margin: 6px 0 0; font-size: 13px; color: var(--muted); }
|
|
24
|
+
main { display: grid; grid-template-columns: 280px 320px 1fr; gap: 12px; padding: 12px; height: calc(100vh - 80px); }
|
|
25
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
|
|
26
|
+
section h2 { margin: 0; padding: 12px; font-size: 14px; border-bottom: 1px solid var(--line); background: var(--panel-light); color: var(--brand); }
|
|
27
|
+
.scroll-area { flex: 1; overflow-y: auto; }
|
|
28
|
+
ul { list-style: none; margin: 0; padding: 0; }
|
|
29
|
+
li { border-bottom: 1px solid var(--line); }
|
|
30
|
+
li:last-child { border-bottom: 0; }
|
|
31
|
+
a.item { display: block; padding: 12px; color: inherit; text-decoration: none; transition: background 0.2s; }
|
|
32
|
+
a.item:hover { background: var(--panel-light); }
|
|
33
|
+
a.item.active { background: rgba(140, 123, 255, 0.15); border-left: 3px solid var(--accent); }
|
|
34
|
+
.meta { display: block; margin-top: 4px; font-size: 11px; color: var(--muted); }
|
|
35
|
+
.status-badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: bold; text-transform: uppercase; }
|
|
36
|
+
.status-active { background: rgba(55, 214, 122, 0.2); color: var(--success); }
|
|
37
|
+
.status-completed { background: rgba(100, 214, 255, 0.2); color: var(--brand); }
|
|
38
|
+
.panel-empty { padding: 20px; color: var(--muted); font-size: 13px; text-align: center; }
|
|
39
|
+
.summary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 12px; }
|
|
40
|
+
.summary-card { background: var(--panel-light); border: 1px solid var(--line); border-radius: 8px; padding: 10px; }
|
|
41
|
+
.summary-card .label { font-size: 10px; color: var(--muted); text-transform: uppercase; }
|
|
42
|
+
.summary-card .value { font-size: 16px; font-weight: bold; margin-top: 4px; }
|
|
43
|
+
table { width: 100%; border-collapse: collapse; }
|
|
44
|
+
th { text-align: left; padding: 10px; font-size: 12px; color: var(--muted); background: var(--panel-light); position: sticky; top: 0; }
|
|
45
|
+
td { padding: 10px; font-size: 12px; border-bottom: 1px solid var(--line); }
|
|
46
|
+
code { font-family: "JetBrains Mono", monospace; color: var(--brand); font-size: 11px; }
|
|
47
|
+
pre { margin: 0; white-space: pre-wrap; word-break: break-all; color: var(--text); }
|
|
48
|
+
@media (max-width: 1080px) {
|
|
49
|
+
main { grid-template-columns: 1fr; height: auto; }
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
function escapeHtml(value) {
|
|
54
|
+
return String(value ?? '')
|
|
55
|
+
.replaceAll('&', '&')
|
|
56
|
+
.replaceAll('<', '<')
|
|
57
|
+
.replaceAll('>', '>')
|
|
58
|
+
.replaceAll('"', '"')
|
|
59
|
+
.replaceAll("'", ''');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toPrettyJson(value) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(value ?? {}, null, 2);
|
|
65
|
+
} catch {
|
|
66
|
+
return '{}';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatError(error) {
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
return error.message;
|
|
73
|
+
}
|
|
74
|
+
if (error && typeof error === 'object') {
|
|
75
|
+
const message = error.message;
|
|
76
|
+
const details = error.details;
|
|
77
|
+
const hint = error.hint;
|
|
78
|
+
const code = error.code;
|
|
79
|
+
const parts = [message, details, hint, code].filter(Boolean);
|
|
80
|
+
if (parts.length > 0) {
|
|
81
|
+
return parts.join(' | ');
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.stringify(error);
|
|
85
|
+
} catch {
|
|
86
|
+
return 'Unknown object error';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return String(error);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toIntegerInRange(raw, fallback, min, max) {
|
|
93
|
+
const value = Number.parseInt(String(raw ?? ''), 10);
|
|
94
|
+
if (!Number.isInteger(value)) {
|
|
95
|
+
return fallback;
|
|
96
|
+
}
|
|
97
|
+
if (value < min) {
|
|
98
|
+
return min;
|
|
99
|
+
}
|
|
100
|
+
if (value > max) {
|
|
101
|
+
return max;
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeStatusFilter(raw) {
|
|
107
|
+
const value = String(raw ?? '').trim().toLowerCase();
|
|
108
|
+
if (value === 'active' || value === 'ended') {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
return 'all';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeActorFilter(raw) {
|
|
115
|
+
const value = String(raw ?? '').trim();
|
|
116
|
+
return value.slice(0, 64);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function aggregateEventsByType(events) {
|
|
120
|
+
const counts = new Map();
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
const key = String(event.type ?? 'unknown');
|
|
123
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
124
|
+
}
|
|
125
|
+
return [...counts.entries()]
|
|
126
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
127
|
+
.map(([type, count]) => ({ type, count }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function aggregateSessionsByActor(sessions) {
|
|
131
|
+
const counts = new Map();
|
|
132
|
+
for (const session of sessions) {
|
|
133
|
+
const key = String(session.actor ?? 'unknown');
|
|
134
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
135
|
+
}
|
|
136
|
+
return [...counts.entries()]
|
|
137
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
138
|
+
.slice(0, 8)
|
|
139
|
+
.map(([actor, count]) => ({ actor, count }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function truncateText(value, maxLength) {
|
|
143
|
+
const text = String(value ?? '');
|
|
144
|
+
if (text.length <= maxLength) {
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
return `${text.slice(0, Math.max(0, maxLength - 1))}...`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasAnyKeyword(text, keywords) {
|
|
151
|
+
return keywords.some((keyword) => text.includes(keyword));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function classifyEventStage(event) {
|
|
155
|
+
const type = String(event?.type ?? '').toLowerCase();
|
|
156
|
+
const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
157
|
+
const payloadKeys = Object.keys(payload).map((key) => key.toLowerCase()).join(' ');
|
|
158
|
+
const payloadText = `${toPrettyJson(payload).toLowerCase()} ${payloadKeys}`;
|
|
159
|
+
const search = `${type} ${payloadText}`;
|
|
160
|
+
|
|
161
|
+
const artifactKeywords = ['artifact', 'output', 'result', 'report', 'diff', 'commit', 'patch', 'release', 'file', 'url'];
|
|
162
|
+
const intentKeywords = ['intent', 'goal', 'plan', 'scope', 'brief', 'task', 'spec', 'decision'];
|
|
163
|
+
const actionKeywords = ['action', 'run', 'exec', 'command', 'webhook', 'sync', 'start', 'resume', 'status', 'deploy', 'build', 'test'];
|
|
164
|
+
|
|
165
|
+
if (hasAnyKeyword(search, artifactKeywords)) {
|
|
166
|
+
return 'artifact';
|
|
167
|
+
}
|
|
168
|
+
if (hasAnyKeyword(search, intentKeywords)) {
|
|
169
|
+
return 'intent';
|
|
170
|
+
}
|
|
171
|
+
if (hasAnyKeyword(search, actionKeywords)) {
|
|
172
|
+
return 'action';
|
|
173
|
+
}
|
|
174
|
+
return 'context';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function summarizeEventPayload(payload) {
|
|
178
|
+
if (payload === null || payload === undefined) {
|
|
179
|
+
return '-';
|
|
180
|
+
}
|
|
181
|
+
if (typeof payload === 'string') {
|
|
182
|
+
return truncateText(payload, 140);
|
|
183
|
+
}
|
|
184
|
+
if (typeof payload !== 'object') {
|
|
185
|
+
return truncateText(String(payload), 140);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const preferredKeys = ['summary', 'message', 'title', 'intent', 'action', 'artifact', 'eventType', 'source', 'status', 'path', 'url', 'ref'];
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const key of preferredKeys) {
|
|
191
|
+
if (parts.length >= 3) {
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
if (!(key in payload)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const raw = payload[key];
|
|
198
|
+
if (raw === null || raw === undefined) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (typeof raw === 'object') {
|
|
202
|
+
parts.push(`${key}=${truncateText(toPrettyJson(raw), 64)}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
parts.push(`${key}=${truncateText(String(raw), 64)}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (parts.length > 0) {
|
|
209
|
+
return truncateText(parts.join(' | '), 160);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const keys = Object.keys(payload);
|
|
213
|
+
if (keys.length === 0) {
|
|
214
|
+
return '{}';
|
|
215
|
+
}
|
|
216
|
+
const preview = keys.slice(0, 3).map((key) => `${key}=${truncateText(String(payload[key]), 48)}`);
|
|
217
|
+
return truncateText(preview.join(' | '), 160);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function gatherPayloadTextCandidates(payload) {
|
|
221
|
+
if (!payload || typeof payload !== 'object') {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
const candidates = [];
|
|
225
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
226
|
+
const lowerKey = key.toLowerCase();
|
|
227
|
+
if (typeof value === 'string') {
|
|
228
|
+
candidates.push({ key: lowerKey, value });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (Array.isArray(value)) {
|
|
232
|
+
for (const item of value) {
|
|
233
|
+
if (typeof item === 'string') {
|
|
234
|
+
candidates.push({ key: lowerKey, value: item });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return candidates;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function extractNextActions(events) {
|
|
243
|
+
const priorityKeys = ['nextaction', 'nextactions', 'todo', 'todos', 'actionitems', 'followup', 'followups', 'openitems'];
|
|
244
|
+
const actions = [];
|
|
245
|
+
for (const event of [...events].reverse()) {
|
|
246
|
+
const payload = event?.payload && typeof event.payload === 'object' ? event.payload : null;
|
|
247
|
+
const candidates = gatherPayloadTextCandidates(payload);
|
|
248
|
+
for (const item of candidates) {
|
|
249
|
+
const normalized = String(item.value ?? '').trim().replace(/\s+/g, ' ');
|
|
250
|
+
if (!normalized) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (priorityKeys.includes(item.key)) {
|
|
254
|
+
actions.push(normalized);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const lower = normalized.toLowerCase();
|
|
258
|
+
if (lower.startsWith('next:') || lower.startsWith('todo:') || lower.startsWith('follow-up:') || lower.startsWith('action:')) {
|
|
259
|
+
actions.push(normalized.replace(/^[^:]+:\s*/i, ''));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (actions.length >= 6) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const deduped = [];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
for (const action of actions) {
|
|
270
|
+
const key = action.toLowerCase();
|
|
271
|
+
if (seen.has(key)) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
seen.add(key);
|
|
275
|
+
deduped.push(action);
|
|
276
|
+
if (deduped.length >= 4) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return deduped;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildHandoffPacket(selectedSession, chainItems, events) {
|
|
284
|
+
const latestByStage = { intent: null, action: null, artifact: null };
|
|
285
|
+
for (const row of [...chainItems].reverse()) {
|
|
286
|
+
if (!latestByStage[row.stage]) {
|
|
287
|
+
latestByStage[row.stage] = row;
|
|
288
|
+
}
|
|
289
|
+
if (latestByStage.intent && latestByStage.action && latestByStage.artifact) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const nextActions = extractNextActions(events);
|
|
295
|
+
const fallbackActions = [];
|
|
296
|
+
if (latestByStage.intent?.summary) {
|
|
297
|
+
fallbackActions.push(`Validate intent alignment: ${latestByStage.intent.summary}`);
|
|
298
|
+
}
|
|
299
|
+
if (latestByStage.action?.summary) {
|
|
300
|
+
fallbackActions.push(`Continue last action path: ${latestByStage.action.summary}`);
|
|
301
|
+
}
|
|
302
|
+
if (latestByStage.artifact?.summary) {
|
|
303
|
+
fallbackActions.push(`Review latest artifact before handoff: ${latestByStage.artifact.summary}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
actor: selectedSession?.actor ?? '-',
|
|
308
|
+
status: selectedSession?.status ?? '-',
|
|
309
|
+
latestIntent: latestByStage.intent?.summary ?? 'No explicit intent event detected.',
|
|
310
|
+
latestAction: latestByStage.action?.summary ?? 'No explicit action event detected.',
|
|
311
|
+
latestArtifact: latestByStage.artifact?.summary ?? 'No explicit artifact event detected.',
|
|
312
|
+
nextActions: nextActions.length > 0 ? nextActions : fallbackActions.slice(0, 3)
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderApp({
|
|
317
|
+
configPath,
|
|
318
|
+
projects,
|
|
319
|
+
selectedProjectId,
|
|
320
|
+
sessions,
|
|
321
|
+
filteredSessions,
|
|
322
|
+
selectedSessionId,
|
|
323
|
+
selectedSession,
|
|
324
|
+
events,
|
|
325
|
+
projectKpis,
|
|
326
|
+
projectTrend,
|
|
327
|
+
tailLimit,
|
|
328
|
+
refreshSeconds,
|
|
329
|
+
sessionStatusFilter,
|
|
330
|
+
actorFilter,
|
|
331
|
+
loadError
|
|
332
|
+
}) {
|
|
333
|
+
const projectItems = projects
|
|
334
|
+
.map((project) => {
|
|
335
|
+
const active = project.id === selectedProjectId ? ' active' : '';
|
|
336
|
+
const href = `/?projectId=${encodeURIComponent(project.id)}`;
|
|
337
|
+
return `<li><a class="item${active}" href="${href}"><strong>${escapeHtml(project.project_key)}</strong><span class="meta">${escapeHtml(project.name)}</span></a></li>`;
|
|
338
|
+
})
|
|
339
|
+
.join('');
|
|
340
|
+
|
|
341
|
+
const sessionItems = filteredSessions
|
|
342
|
+
.map((session) => {
|
|
343
|
+
const active = session.id === selectedSessionId ? ' active' : '';
|
|
344
|
+
const href = `/?projectId=${encodeURIComponent(selectedProjectId)}&sessionId=${encodeURIComponent(session.id)}&tail=${tailLimit}&refresh=${refreshSeconds}&sessionStatus=${encodeURIComponent(sessionStatusFilter)}&actor=${encodeURIComponent(actorFilter)}`;
|
|
345
|
+
return `<li><a class="item${active}" href="${href}"><strong>${escapeHtml(session.actor)}</strong><span class="meta">${escapeHtml(session.id)} | ${escapeHtml(session.status)} | ${escapeHtml(session.started_at)}</span></a></li>`;
|
|
346
|
+
})
|
|
347
|
+
.join('');
|
|
348
|
+
|
|
349
|
+
const activeSessions = sessions.filter((session) => session.status === 'active').length;
|
|
350
|
+
const endedSessions = sessions.filter((session) => session.status === 'ended').length;
|
|
351
|
+
const latestEventAt = events.length > 0 ? events[events.length - 1].created_at : '-';
|
|
352
|
+
const eventTypeBreakdown = aggregateEventsByType(events);
|
|
353
|
+
const chainItems = events.map((event, index) => ({
|
|
354
|
+
step: index + 1,
|
|
355
|
+
type: String(event.type ?? 'unknown'),
|
|
356
|
+
stage: classifyEventStage(event),
|
|
357
|
+
createdAt: event.created_at,
|
|
358
|
+
summary: summarizeEventPayload(event.payload)
|
|
359
|
+
}));
|
|
360
|
+
const chainStageCounts = chainItems.reduce(
|
|
361
|
+
(acc, row) => {
|
|
362
|
+
acc[row.stage] = (acc[row.stage] ?? 0) + 1;
|
|
363
|
+
return acc;
|
|
364
|
+
},
|
|
365
|
+
{ intent: 0, action: 0, artifact: 0, context: 0 }
|
|
366
|
+
);
|
|
367
|
+
const handoffPacket = buildHandoffPacket(selectedSession, chainItems, events);
|
|
368
|
+
const handoffNextActionsHtml =
|
|
369
|
+
handoffPacket.nextActions.length === 0
|
|
370
|
+
? '<li>No inferred next action. Add explicit `nextAction` or `todo` fields in event payloads.</li>'
|
|
371
|
+
: handoffPacket.nextActions.map((item) => `<li>${escapeHtml(item)}</li>`).join('');
|
|
372
|
+
const chainRows =
|
|
373
|
+
chainItems.length === 0
|
|
374
|
+
? '<li class="chain-item"><div class="chain-summary">No events in the selected session.</div></li>'
|
|
375
|
+
: chainItems
|
|
376
|
+
.map(
|
|
377
|
+
(row) =>
|
|
378
|
+
`<li class="chain-item"><div class="chain-top"><span><strong>#${row.step}</strong> <span class="chain-type">${escapeHtml(
|
|
379
|
+
row.type
|
|
380
|
+
)}</span></span><span class="chain-stage ${row.stage}">${escapeHtml(row.stage)}</span><span class="chain-time">${escapeHtml(
|
|
381
|
+
row.createdAt
|
|
382
|
+
)}</span></div><div class="chain-summary">${escapeHtml(row.summary)}</div></li>`
|
|
383
|
+
)
|
|
384
|
+
.join('');
|
|
385
|
+
const topActors = aggregateSessionsByActor(sessions);
|
|
386
|
+
const eventRows = events
|
|
387
|
+
.map(
|
|
388
|
+
(event) =>
|
|
389
|
+
`<tr><td>${escapeHtml(event.created_at)}</td><td>${escapeHtml(event.type)}</td><td><code>${escapeHtml(toPrettyJson(event.payload))}</code></td></tr>`
|
|
390
|
+
)
|
|
391
|
+
.join('');
|
|
392
|
+
const refreshMeta = refreshSeconds > 0 ? `Auto refresh ${refreshSeconds}s` : 'Manual refresh';
|
|
393
|
+
const refreshTag =
|
|
394
|
+
refreshSeconds > 0
|
|
395
|
+
? `<meta http-equiv="refresh" content="${refreshSeconds}" />`
|
|
396
|
+
: '';
|
|
397
|
+
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null;
|
|
398
|
+
const statusLabel = sessionStatusFilter === 'all' ? 'All statuses' : sessionStatusFilter;
|
|
399
|
+
const actorLabel = actorFilter || 'All actors';
|
|
400
|
+
const controlsProjectId = selectedProjectId ? `<input type="hidden" name="projectId" value="${escapeHtml(selectedProjectId)}" />` : '';
|
|
401
|
+
const eventBreakdownItems =
|
|
402
|
+
eventTypeBreakdown.length === 0
|
|
403
|
+
? '<li><span>No events</span><span>0</span></li>'
|
|
404
|
+
: eventTypeBreakdown
|
|
405
|
+
.map((row) => `<li><span><code>${escapeHtml(row.type)}</code></span><span>${row.count}</span></li>`)
|
|
406
|
+
.join('');
|
|
407
|
+
const actorItems =
|
|
408
|
+
topActors.length === 0
|
|
409
|
+
? '<li><span>No sessions</span><span>0</span></li>'
|
|
410
|
+
: topActors.map((row) => `<li><span>${escapeHtml(row.actor)}</span><span>${row.count}</span></li>`).join('');
|
|
411
|
+
|
|
412
|
+
return `<!doctype html>
|
|
413
|
+
<html lang="en">
|
|
414
|
+
<head>
|
|
415
|
+
<meta charset="utf-8" />
|
|
416
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
417
|
+
<title>OpenSession Viewer</title>
|
|
418
|
+
${refreshTag}
|
|
419
|
+
<style>${CSS}</style>
|
|
420
|
+
</head>
|
|
421
|
+
<body>
|
|
422
|
+
<header>
|
|
423
|
+
<h1>OpenSession Read-Only Viewer</h1>
|
|
424
|
+
<p>Config: ${escapeHtml(configPath)} | Read-only mode (GET only)</p>
|
|
425
|
+
${
|
|
426
|
+
loadError
|
|
427
|
+
? `<p>Data load error: ${escapeHtml(loadError)}</p>`
|
|
428
|
+
: ''
|
|
429
|
+
}
|
|
430
|
+
</header>
|
|
431
|
+
<main>
|
|
432
|
+
<section>
|
|
433
|
+
<h2>Projects (${projects.length})</h2>
|
|
434
|
+
${projects.length === 0 ? '<div class="panel-empty">No projects found.</div>' : `<ul>${projectItems}</ul>`}
|
|
435
|
+
</section>
|
|
436
|
+
<section>
|
|
437
|
+
<h2>Sessions (${filteredSessions.length}/${sessions.length})</h2>
|
|
438
|
+
${filteredSessions.length === 0 ? '<div class="panel-empty">No sessions match current filter.</div>' : `<ul>${sessionItems}</ul>`}
|
|
439
|
+
</section>
|
|
440
|
+
<div class="content-col">
|
|
441
|
+
<section>
|
|
442
|
+
<h2>Usage Summary</h2>
|
|
443
|
+
<div class="summary-grid">
|
|
444
|
+
<div class="summary-card"><span class="label">Projects</span><span class="value">${projects.length}</span></div>
|
|
445
|
+
<div class="summary-card"><span class="label">Project Sessions</span><span class="value">${sessions.length}</span></div>
|
|
446
|
+
<div class="summary-card"><span class="label">Active Sessions</span><span class="value">${projectKpis?.activeSessions ?? activeSessions}</span></div>
|
|
447
|
+
<div class="summary-card"><span class="label">Ended Sessions</span><span class="value">${endedSessions}</span></div>
|
|
448
|
+
<div class="summary-card"><span class="label">28d Events</span><span class="value">${projectKpis?.totalEvents ?? 0}</span></div>
|
|
449
|
+
<div class="summary-card"><span class="label">28d Actors</span><span class="value">${projectKpis?.uniqueActors ?? 0}</span></div>
|
|
450
|
+
<div class="summary-card"><span class="label">Events / Session</span><span class="value">${(projectKpis?.eventsPerSession ?? 0).toFixed(2)}</span></div>
|
|
451
|
+
<div class="summary-card"><span class="label">Tail Events</span><span class="value">${events.length}</span></div>
|
|
452
|
+
<div class="summary-card"><span class="label">Status Filter</span><span class="value">${escapeHtml(statusLabel)}</span></div>
|
|
453
|
+
<div class="summary-card"><span class="label">Actor Filter</span><span class="value">${escapeHtml(actorLabel)}</span></div>
|
|
454
|
+
<div class="summary-card"><span class="label">Top Event Type</span><span class="value">${escapeHtml(eventTypeBreakdown[0]?.type ?? '-')}</span></div>
|
|
455
|
+
<div class="summary-card"><span class="label">Intent Nodes</span><span class="value">${chainStageCounts.intent}</span></div>
|
|
456
|
+
<div class="summary-card"><span class="label">Action Nodes</span><span class="value">${chainStageCounts.action}</span></div>
|
|
457
|
+
<div class="summary-card"><span class="label">Artifact Nodes</span><span class="value">${chainStageCounts.artifact}</span></div>
|
|
458
|
+
</div>
|
|
459
|
+
</section>
|
|
460
|
+
<section>
|
|
461
|
+
<h2>Weekly Trend (28d)</h2>
|
|
462
|
+
${
|
|
463
|
+
projectTrend.length === 0
|
|
464
|
+
? '<div class="panel-empty">No trend data available.</div>'
|
|
465
|
+
: `<table class="trend-table"><thead><tr><th>Week Start</th><th>Sessions</th><th>Actors</th><th>Events</th></tr></thead><tbody>${projectTrend
|
|
466
|
+
.map(
|
|
467
|
+
(bucket) =>
|
|
468
|
+
`<tr><td>${escapeHtml(bucket.weekStart)}</td><td>${bucket.sessions}</td><td>${bucket.uniqueActors}</td><td>${bucket.events}</td></tr>`
|
|
469
|
+
)
|
|
470
|
+
.join('')}</tbody></table>`
|
|
471
|
+
}
|
|
472
|
+
</section>
|
|
473
|
+
<section>
|
|
474
|
+
<h2>Viewer Controls</h2>
|
|
475
|
+
<form class="controls" method="get" action="/">
|
|
476
|
+
${controlsProjectId}
|
|
477
|
+
<div class="controls-grid">
|
|
478
|
+
<label>Session status
|
|
479
|
+
<select name="sessionStatus">
|
|
480
|
+
<option value="all"${sessionStatusFilter === 'all' ? ' selected' : ''}>all</option>
|
|
481
|
+
<option value="active"${sessionStatusFilter === 'active' ? ' selected' : ''}>active</option>
|
|
482
|
+
<option value="ended"${sessionStatusFilter === 'ended' ? ' selected' : ''}>ended</option>
|
|
483
|
+
</select>
|
|
484
|
+
</label>
|
|
485
|
+
<label>Actor contains
|
|
486
|
+
<input name="actor" value="${escapeHtml(actorFilter)}" placeholder="mane" />
|
|
487
|
+
</label>
|
|
488
|
+
<label>Tail events
|
|
489
|
+
<input name="tail" value="${tailLimit}" />
|
|
490
|
+
</label>
|
|
491
|
+
<label>Refresh seconds
|
|
492
|
+
<input name="refresh" value="${refreshSeconds}" />
|
|
493
|
+
</label>
|
|
494
|
+
</div>
|
|
495
|
+
<div class="controls-actions">
|
|
496
|
+
<button class="button" type="submit">Apply</button>
|
|
497
|
+
<a class="button secondary" href="/?projectId=${encodeURIComponent(selectedProjectId ?? '')}">Reset</a>
|
|
498
|
+
</div>
|
|
499
|
+
</form>
|
|
500
|
+
</section>
|
|
501
|
+
<section>
|
|
502
|
+
<h2>Session Details</h2>
|
|
503
|
+
${
|
|
504
|
+
selectedSession
|
|
505
|
+
? `<div class="details">
|
|
506
|
+
<div class="details-row"><span class="details-key">Project</span><span>${escapeHtml(selectedProject?.project_key ?? '-')}</span></div>
|
|
507
|
+
<div class="details-row"><span class="details-key">Session ID</span><span><code>${escapeHtml(selectedSession.id)}</code></span></div>
|
|
508
|
+
<div class="details-row"><span class="details-key">Actor</span><span>${escapeHtml(selectedSession.actor)}</span></div>
|
|
509
|
+
<div class="details-row"><span class="details-key">Status</span><span>${escapeHtml(selectedSession.status)}</span></div>
|
|
510
|
+
<div class="details-row"><span class="details-key">Started</span><span>${escapeHtml(selectedSession.started_at)}</span></div>
|
|
511
|
+
<div class="details-row"><span class="details-key">Ended</span><span>${escapeHtml(selectedSession.ended_at ?? '-')}</span></div>
|
|
512
|
+
<div class="details-row"><span class="details-key">Latest Event</span><span>${escapeHtml(latestEventAt)}</span></div>
|
|
513
|
+
</div>`
|
|
514
|
+
: '<div class="panel-empty">Select a session to view details.</div>'
|
|
515
|
+
}
|
|
516
|
+
</section>
|
|
517
|
+
<section>
|
|
518
|
+
<h2>Session Chain (Intent -> Action -> Artifact)</h2>
|
|
519
|
+
${
|
|
520
|
+
selectedSession
|
|
521
|
+
? `<ul class="chain-list">${chainRows}</ul>`
|
|
522
|
+
: '<div class="panel-empty">Select a session to inspect the chain.</div>'
|
|
523
|
+
}
|
|
524
|
+
</section>
|
|
525
|
+
<section>
|
|
526
|
+
<h2>Handoff Panel</h2>
|
|
527
|
+
${
|
|
528
|
+
selectedSession
|
|
529
|
+
? `<div class="handoff-panel">
|
|
530
|
+
<div class="handoff-summary">
|
|
531
|
+
<div class="handoff-summary-row"><span class="handoff-label">Actor</span><span class="handoff-value">${escapeHtml(handoffPacket.actor)}</span></div>
|
|
532
|
+
<div class="handoff-summary-row"><span class="handoff-label">Status</span><span class="handoff-value">${escapeHtml(handoffPacket.status)}</span></div>
|
|
533
|
+
<div class="handoff-summary-row"><span class="handoff-label">Intent</span><span class="handoff-value">${escapeHtml(handoffPacket.latestIntent)}</span></div>
|
|
534
|
+
<div class="handoff-summary-row"><span class="handoff-label">Action</span><span class="handoff-value">${escapeHtml(handoffPacket.latestAction)}</span></div>
|
|
535
|
+
<div class="handoff-summary-row"><span class="handoff-label">Artifact</span><span class="handoff-value">${escapeHtml(handoffPacket.latestArtifact)}</span></div>
|
|
536
|
+
</div>
|
|
537
|
+
<div class="next-actions">
|
|
538
|
+
<h3>Next Actions</h3>
|
|
539
|
+
<ol>${handoffNextActionsHtml}</ol>
|
|
540
|
+
</div>
|
|
541
|
+
</div>`
|
|
542
|
+
: '<div class="panel-empty">Select a session to generate handoff packet summary and next actions.</div>'
|
|
543
|
+
}
|
|
544
|
+
</section>
|
|
545
|
+
<section>
|
|
546
|
+
<h2>Event Type Breakdown</h2>
|
|
547
|
+
<ul class="mini-list">${eventBreakdownItems}</ul>
|
|
548
|
+
</section>
|
|
549
|
+
<section>
|
|
550
|
+
<h2>Actor Session Distribution</h2>
|
|
551
|
+
<ul class="mini-list">${actorItems}</ul>
|
|
552
|
+
</section>
|
|
553
|
+
<section>
|
|
554
|
+
<h2 class="events-header"><span>Events Tail</span><span class="events-meta">${escapeHtml(refreshMeta)} | tail=${tailLimit}</span></h2>
|
|
555
|
+
${
|
|
556
|
+
events.length === 0
|
|
557
|
+
? '<div class="panel-empty">Select a session to view events.</div>'
|
|
558
|
+
: `<table><thead><tr><th>Time</th><th>Type</th><th>Payload</th></tr></thead><tbody>${eventRows}</tbody></table>`
|
|
559
|
+
}
|
|
560
|
+
</section>
|
|
561
|
+
</div>
|
|
562
|
+
</main>
|
|
563
|
+
</body>
|
|
564
|
+
</html>`;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function sendJson(res, statusCode, payload) {
|
|
568
|
+
const body = JSON.stringify(payload);
|
|
569
|
+
res.writeHead(statusCode, {
|
|
570
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
571
|
+
'Content-Length': Buffer.byteLength(body)
|
|
572
|
+
});
|
|
573
|
+
res.end(body);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function sendHtml(res, statusCode, body) {
|
|
577
|
+
res.writeHead(statusCode, {
|
|
578
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
579
|
+
'Content-Length': Buffer.byteLength(body)
|
|
580
|
+
});
|
|
581
|
+
res.end(body);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export async function startViewerServer({ host, port }) {
|
|
585
|
+
const config = await readConfig();
|
|
586
|
+
const client = getClient(config);
|
|
587
|
+
const configPath = getConfigPath();
|
|
588
|
+
|
|
589
|
+
const server = http.createServer(async (req, res) => {
|
|
590
|
+
try {
|
|
591
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
592
|
+
sendJson(res, 405, { error: 'Method not allowed. Viewer is read-only (GET/HEAD only).' });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? `${host}:${port}`}`);
|
|
597
|
+
|
|
598
|
+
if (url.pathname === '/health') {
|
|
599
|
+
sendJson(res, 200, { ok: true, mode: 'read-only' });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (url.pathname !== '/') {
|
|
604
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
let projects = [];
|
|
609
|
+
let sessions = [];
|
|
610
|
+
let selectedSession = null;
|
|
611
|
+
let events = [];
|
|
612
|
+
let projectKpis = null;
|
|
613
|
+
let projectTrend = [];
|
|
614
|
+
let loadError = null;
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
projects = await listProjects(client, 100);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
loadError = formatError(error);
|
|
620
|
+
}
|
|
621
|
+
const projectIdFromQuery = url.searchParams.get('projectId');
|
|
622
|
+
const selectedProjectId = projects.some((item) => item.id === projectIdFromQuery)
|
|
623
|
+
? projectIdFromQuery
|
|
624
|
+
: projects[0]?.id ?? null;
|
|
625
|
+
|
|
626
|
+
if (selectedProjectId && !loadError) {
|
|
627
|
+
try {
|
|
628
|
+
sessions = await listSessions(client, selectedProjectId, 200);
|
|
629
|
+
const now = new Date();
|
|
630
|
+
const start = new Date(now);
|
|
631
|
+
start.setUTCDate(start.getUTCDate() - 28);
|
|
632
|
+
const sessionIds = sessions.map((session) => session.id);
|
|
633
|
+
const projectEvents = await listSessionEvents(client, sessionIds, {
|
|
634
|
+
since: start.toISOString(),
|
|
635
|
+
until: now.toISOString(),
|
|
636
|
+
ascending: true
|
|
637
|
+
});
|
|
638
|
+
projectKpis = computeKpis(sessions, projectEvents);
|
|
639
|
+
projectTrend = computeWeeklyTrend(sessions, projectEvents, 4, now);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
loadError = formatError(error);
|
|
642
|
+
sessions = [];
|
|
643
|
+
projectKpis = null;
|
|
644
|
+
projectTrend = [];
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const sessionIdFromQuery = url.searchParams.get('sessionId');
|
|
648
|
+
const tailLimit = toIntegerInRange(url.searchParams.get('tail'), 200, 1, 500);
|
|
649
|
+
const refreshSeconds = toIntegerInRange(url.searchParams.get('refresh'), 0, 0, 60);
|
|
650
|
+
const sessionStatusFilter = normalizeStatusFilter(url.searchParams.get('sessionStatus'));
|
|
651
|
+
const actorFilter = normalizeActorFilter(url.searchParams.get('actor'));
|
|
652
|
+
const actorFilterNeedle = actorFilter.toLowerCase();
|
|
653
|
+
const filteredSessions = sessions.filter((session) => {
|
|
654
|
+
if (sessionStatusFilter !== 'all' && session.status !== sessionStatusFilter) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
if (actorFilterNeedle && !String(session.actor ?? '').toLowerCase().includes(actorFilterNeedle)) {
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
});
|
|
662
|
+
const selectedSessionId = filteredSessions.some((item) => item.id === sessionIdFromQuery)
|
|
663
|
+
? sessionIdFromQuery
|
|
664
|
+
: filteredSessions[0]?.id ?? null;
|
|
665
|
+
|
|
666
|
+
selectedSession = filteredSessions.find((session) => session.id === selectedSessionId) ?? null;
|
|
667
|
+
|
|
668
|
+
if (selectedSessionId && !loadError) {
|
|
669
|
+
try {
|
|
670
|
+
events = await getSessionEvents(client, selectedSessionId, tailLimit, { ascending: false });
|
|
671
|
+
events.reverse();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
loadError = formatError(error);
|
|
674
|
+
events = [];
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const body = renderApp({
|
|
679
|
+
configPath,
|
|
680
|
+
projects,
|
|
681
|
+
selectedProjectId,
|
|
682
|
+
sessions,
|
|
683
|
+
filteredSessions,
|
|
684
|
+
selectedSessionId,
|
|
685
|
+
selectedSession,
|
|
686
|
+
events,
|
|
687
|
+
projectKpis,
|
|
688
|
+
projectTrend,
|
|
689
|
+
tailLimit,
|
|
690
|
+
refreshSeconds,
|
|
691
|
+
sessionStatusFilter,
|
|
692
|
+
actorFilter,
|
|
693
|
+
loadError
|
|
694
|
+
});
|
|
695
|
+
sendHtml(res, 200, body);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
const message = formatError(error);
|
|
698
|
+
sendJson(res, 500, { error: message });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
await new Promise((resolve, reject) => {
|
|
703
|
+
server.once('error', reject);
|
|
704
|
+
server.listen(port, host, resolve);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
return { server, url: `http://${host}:${port}/` };
|
|
708
|
+
}
|