laminark 2.21.6
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/.claude-plugin/marketplace.json +15 -0
- package/README.md +182 -0
- package/package.json +63 -0
- package/plugin/.claude-plugin/plugin.json +13 -0
- package/plugin/.mcp.json +12 -0
- package/plugin/dist/analysis/worker.d.ts +1 -0
- package/plugin/dist/analysis/worker.js +233 -0
- package/plugin/dist/analysis/worker.js.map +1 -0
- package/plugin/dist/config-t8LZeB-u.mjs +90 -0
- package/plugin/dist/config-t8LZeB-u.mjs.map +1 -0
- package/plugin/dist/hooks/handler.d.ts +284 -0
- package/plugin/dist/hooks/handler.d.ts.map +1 -0
- package/plugin/dist/hooks/handler.js +2125 -0
- package/plugin/dist/hooks/handler.js.map +1 -0
- package/plugin/dist/index.d.ts +445 -0
- package/plugin/dist/index.d.ts.map +1 -0
- package/plugin/dist/index.js +5831 -0
- package/plugin/dist/index.js.map +1 -0
- package/plugin/dist/observations-Ch0nc47i.d.mts +170 -0
- package/plugin/dist/observations-Ch0nc47i.d.mts.map +1 -0
- package/plugin/dist/tool-registry-CZ3mJ4iR.mjs +2655 -0
- package/plugin/dist/tool-registry-CZ3mJ4iR.mjs.map +1 -0
- package/plugin/hooks/hooks.json +78 -0
- package/plugin/scripts/README.md +47 -0
- package/plugin/scripts/bump-version.sh +44 -0
- package/plugin/scripts/ensure-deps.sh +12 -0
- package/plugin/scripts/install.sh +63 -0
- package/plugin/scripts/local-install.sh +103 -0
- package/plugin/scripts/setup-tmpdir.sh +65 -0
- package/plugin/scripts/uninstall.sh +95 -0
- package/plugin/scripts/update.sh +88 -0
- package/plugin/scripts/verify-install.sh +43 -0
- package/plugin/ui/activity.js +185 -0
- package/plugin/ui/app.js +1642 -0
- package/plugin/ui/graph.js +2333 -0
- package/plugin/ui/help.js +228 -0
- package/plugin/ui/index.html +492 -0
- package/plugin/ui/settings.js +650 -0
- package/plugin/ui/styles.css +2910 -0
- package/plugin/ui/timeline.js +652 -0
package/plugin/ui/app.js
ADDED
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Laminark client-side application.
|
|
3
|
+
*
|
|
4
|
+
* Handles tab navigation, SSE connection with auto-reconnect,
|
|
5
|
+
* REST API data fetching, and initial state loading.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Global state
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
window.laminarkState = {
|
|
13
|
+
graph: null,
|
|
14
|
+
timeline: null,
|
|
15
|
+
graphInitialized: false,
|
|
16
|
+
timelineInitialized: false,
|
|
17
|
+
currentProject: null,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// REST API helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetches graph data (nodes + edges) from the REST API.
|
|
26
|
+
* @param {Object} [filters] - Optional filters
|
|
27
|
+
* @param {string} [filters.type] - Comma-separated entity types
|
|
28
|
+
* @param {string} [filters.since] - ISO8601 timestamp
|
|
29
|
+
* @returns {Promise<{nodes: Array, edges: Array}>}
|
|
30
|
+
*/
|
|
31
|
+
async function fetchGraphData(filters) {
|
|
32
|
+
const params = new URLSearchParams();
|
|
33
|
+
if (filters?.type) params.set('type', filters.type);
|
|
34
|
+
if (filters?.since) params.set('since', filters.since);
|
|
35
|
+
if (filters?.until) params.set('until', filters.until);
|
|
36
|
+
if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
|
|
37
|
+
|
|
38
|
+
const url = '/api/graph' + (params.toString() ? '?' + params.toString() : '');
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(url);
|
|
42
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
43
|
+
return await res.json();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('[laminark] Failed to fetch graph data:', err);
|
|
46
|
+
return { nodes: [], edges: [] };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetches timeline data (sessions, observations, topic shifts).
|
|
52
|
+
* @param {Object} [range] - Optional time range
|
|
53
|
+
* @param {string} [range.from] - ISO8601 start
|
|
54
|
+
* @param {string} [range.to] - ISO8601 end
|
|
55
|
+
* @param {number} [range.limit] - Max observations
|
|
56
|
+
* @returns {Promise<{sessions: Array, observations: Array, topicShifts: Array}>}
|
|
57
|
+
*/
|
|
58
|
+
async function fetchTimelineData(range) {
|
|
59
|
+
const params = new URLSearchParams();
|
|
60
|
+
if (range?.from) params.set('from', range.from);
|
|
61
|
+
if (range?.to) params.set('to', range.to);
|
|
62
|
+
if (range?.limit) params.set('limit', String(range.limit));
|
|
63
|
+
if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
|
|
64
|
+
|
|
65
|
+
const url = '/api/timeline' + (params.toString() ? '?' + params.toString() : '');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(url);
|
|
69
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
70
|
+
return await res.json();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error('[laminark] Failed to fetch timeline data:', err);
|
|
73
|
+
return { sessions: [], observations: [], topicShifts: [] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Fetches detailed info for a single graph node.
|
|
79
|
+
* @param {string} id - Node ID
|
|
80
|
+
* @returns {Promise<{entity: Object, observations: Array, relationships: Array}|null>}
|
|
81
|
+
*/
|
|
82
|
+
async function fetchNodeDetails(id) {
|
|
83
|
+
try {
|
|
84
|
+
const res = await fetch(`/api/node/${encodeURIComponent(id)}`);
|
|
85
|
+
if (!res.ok) return null;
|
|
86
|
+
return await res.json();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error('[laminark] Failed to fetch node details:', err);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fetches available projects from the REST API.
|
|
95
|
+
* @returns {Promise<{projects: Array, defaultProject: string|null}>}
|
|
96
|
+
*/
|
|
97
|
+
async function fetchProjects() {
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch('/api/projects');
|
|
100
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
101
|
+
return await res.json();
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error('[laminark] Failed to fetch projects:', err);
|
|
104
|
+
return { projects: [], defaultProject: null };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetches recent debug paths from the REST API.
|
|
110
|
+
* @returns {Promise<{paths: Array}>}
|
|
111
|
+
*/
|
|
112
|
+
async function fetchPaths() {
|
|
113
|
+
try {
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
if (window.laminarkState.currentProject) params.set('project', window.laminarkState.currentProject);
|
|
116
|
+
const res = await fetch('/api/paths' + (params.toString() ? '?' + params.toString() : ''));
|
|
117
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
118
|
+
return await res.json();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('[laminark] Failed to fetch paths:', err);
|
|
121
|
+
return { paths: [] };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fetches detail for a single debug path including waypoints.
|
|
127
|
+
* @param {string} pathId - Path ID
|
|
128
|
+
* @returns {Promise<{path: Object, waypoints: Array}|null>}
|
|
129
|
+
*/
|
|
130
|
+
async function fetchPathDetail(pathId) {
|
|
131
|
+
try {
|
|
132
|
+
const res = await fetch('/api/paths/' + encodeURIComponent(pathId));
|
|
133
|
+
if (!res.ok) return null;
|
|
134
|
+
return await res.json();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error('[laminark] Failed to fetch path detail:', err);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// SSE connection with auto-reconnect and heartbeat watchdog
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
let eventSource = null;
|
|
146
|
+
let reconnectDelay = 3000;
|
|
147
|
+
const MAX_RECONNECT_DELAY = 30000;
|
|
148
|
+
const HEARTBEAT_TIMEOUT_MS = 60000; // 60s with no heartbeat triggers reconnect
|
|
149
|
+
let lastEventTime = 0;
|
|
150
|
+
let heartbeatWatchdog = null;
|
|
151
|
+
|
|
152
|
+
function updateSSEStatus(status) {
|
|
153
|
+
var indicator = document.getElementById('sse-status');
|
|
154
|
+
if (indicator) {
|
|
155
|
+
indicator.className = 'status-indicator ' + status;
|
|
156
|
+
updateSSETooltip(indicator, status);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function updateSSETooltip(indicator, status) {
|
|
161
|
+
var base = 'SSE: ' + status;
|
|
162
|
+
if (lastEventTime > 0) {
|
|
163
|
+
var ago = Math.round((Date.now() - lastEventTime) / 1000);
|
|
164
|
+
var agoText = ago < 60 ? ago + 's ago' : Math.round(ago / 60) + 'min ago';
|
|
165
|
+
indicator.title = base + ' (last event: ' + agoText + ')';
|
|
166
|
+
} else {
|
|
167
|
+
indicator.title = base;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function recordEventReceived() {
|
|
172
|
+
lastEventTime = Date.now();
|
|
173
|
+
resetHeartbeatWatchdog();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resetHeartbeatWatchdog() {
|
|
177
|
+
if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
|
|
178
|
+
heartbeatWatchdog = setTimeout(function () {
|
|
179
|
+
console.warn('[laminark] No heartbeat for ' + (HEARTBEAT_TIMEOUT_MS / 1000) + 's, forcing SSE reconnect');
|
|
180
|
+
if (eventSource) {
|
|
181
|
+
eventSource.close();
|
|
182
|
+
eventSource = null;
|
|
183
|
+
}
|
|
184
|
+
updateSSEStatus('disconnected');
|
|
185
|
+
reconnectWithCatchup();
|
|
186
|
+
}, HEARTBEAT_TIMEOUT_MS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reconnect SSE and fetch fresh data from REST API to catch up on missed events.
|
|
191
|
+
*/
|
|
192
|
+
function reconnectWithCatchup() {
|
|
193
|
+
setTimeout(function () {
|
|
194
|
+
console.log('[laminark] Reconnecting SSE with data catch-up');
|
|
195
|
+
connectSSE();
|
|
196
|
+
|
|
197
|
+
// Belt-and-suspenders: fetch fresh data from REST API to catch up
|
|
198
|
+
if (window.laminarkGraph && window.laminarkState.graphInitialized) {
|
|
199
|
+
var filters = getActiveFilters();
|
|
200
|
+
window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
|
|
201
|
+
}
|
|
202
|
+
if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
|
|
203
|
+
window.laminarkTimeline.loadTimelineData();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
207
|
+
}, reconnectDelay);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function connectSSE() {
|
|
211
|
+
updateSSEStatus('connecting');
|
|
212
|
+
|
|
213
|
+
eventSource = new EventSource('/api/sse');
|
|
214
|
+
|
|
215
|
+
eventSource.addEventListener('connected', function (e) {
|
|
216
|
+
console.log('[laminark] SSE connected:', JSON.parse(e.data));
|
|
217
|
+
updateSSEStatus('connected');
|
|
218
|
+
reconnectDelay = 3000; // Reset backoff on successful connection
|
|
219
|
+
recordEventReceived();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
eventSource.addEventListener('heartbeat', function (_e) {
|
|
223
|
+
// Heartbeat received -- connection is alive
|
|
224
|
+
updateSSEStatus('connected');
|
|
225
|
+
recordEventReceived();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
eventSource.addEventListener('new_observation', function (e) {
|
|
229
|
+
var data = JSON.parse(e.data);
|
|
230
|
+
recordEventReceived();
|
|
231
|
+
document.dispatchEvent(new CustomEvent('laminark:new_observation', { detail: data }));
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
eventSource.addEventListener('entity_updated', function (e) {
|
|
235
|
+
var data = JSON.parse(e.data);
|
|
236
|
+
recordEventReceived();
|
|
237
|
+
document.dispatchEvent(new CustomEvent('laminark:entity_updated', { detail: data }));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
eventSource.addEventListener('topic_shift', function (e) {
|
|
241
|
+
var data = JSON.parse(e.data);
|
|
242
|
+
recordEventReceived();
|
|
243
|
+
document.dispatchEvent(new CustomEvent('laminark:topic_shift', { detail: data }));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
eventSource.addEventListener('session_start', function (e) {
|
|
247
|
+
var data = JSON.parse(e.data);
|
|
248
|
+
recordEventReceived();
|
|
249
|
+
document.dispatchEvent(new CustomEvent('laminark:session_start', { detail: data }));
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
eventSource.addEventListener('session_end', function (e) {
|
|
253
|
+
var data = JSON.parse(e.data);
|
|
254
|
+
recordEventReceived();
|
|
255
|
+
document.dispatchEvent(new CustomEvent('laminark:session_end', { detail: data }));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
eventSource.addEventListener('path_started', function (e) {
|
|
259
|
+
var data = JSON.parse(e.data);
|
|
260
|
+
recordEventReceived();
|
|
261
|
+
document.dispatchEvent(new CustomEvent('laminark:path_started', { detail: data }));
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
eventSource.addEventListener('path_waypoint', function (e) {
|
|
265
|
+
var data = JSON.parse(e.data);
|
|
266
|
+
recordEventReceived();
|
|
267
|
+
document.dispatchEvent(new CustomEvent('laminark:path_waypoint', { detail: data }));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
eventSource.addEventListener('path_resolved', function (e) {
|
|
271
|
+
var data = JSON.parse(e.data);
|
|
272
|
+
recordEventReceived();
|
|
273
|
+
document.dispatchEvent(new CustomEvent('laminark:path_resolved', { detail: data }));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
eventSource.onerror = function () {
|
|
277
|
+
console.warn('[laminark] SSE connection error, reconnecting in', reconnectDelay, 'ms');
|
|
278
|
+
updateSSEStatus('disconnected');
|
|
279
|
+
eventSource.close();
|
|
280
|
+
eventSource = null;
|
|
281
|
+
|
|
282
|
+
if (heartbeatWatchdog) clearTimeout(heartbeatWatchdog);
|
|
283
|
+
reconnectWithCatchup();
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// Project selector
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
async function initProjectSelector() {
|
|
292
|
+
var select = document.getElementById('project-selector');
|
|
293
|
+
if (!select) return;
|
|
294
|
+
|
|
295
|
+
var data = await fetchProjects();
|
|
296
|
+
var projects = data.projects || [];
|
|
297
|
+
var defaultProject = data.defaultProject;
|
|
298
|
+
|
|
299
|
+
select.innerHTML = '';
|
|
300
|
+
|
|
301
|
+
if (projects.length === 0) {
|
|
302
|
+
var opt = document.createElement('option');
|
|
303
|
+
opt.value = '';
|
|
304
|
+
opt.textContent = 'No projects';
|
|
305
|
+
select.appendChild(opt);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
projects.forEach(function (p) {
|
|
310
|
+
var opt = document.createElement('option');
|
|
311
|
+
opt.value = p.hash;
|
|
312
|
+
opt.textContent = p.displayName;
|
|
313
|
+
if (p.hash === defaultProject) opt.selected = true;
|
|
314
|
+
select.appendChild(opt);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Set initial current project
|
|
318
|
+
window.laminarkState.currentProject = select.value || defaultProject;
|
|
319
|
+
|
|
320
|
+
select.addEventListener('change', function () {
|
|
321
|
+
window.laminarkState.currentProject = select.value;
|
|
322
|
+
// Reload all data for the new project
|
|
323
|
+
if (window.laminarkGraph && window.laminarkState.graphInitialized) {
|
|
324
|
+
window.laminarkGraph.loadGraphData();
|
|
325
|
+
}
|
|
326
|
+
if (window.laminarkTimeline && window.laminarkState.timelineInitialized) {
|
|
327
|
+
window.laminarkTimeline.loadTimelineData();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Tab navigation
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function initNavigation() {
|
|
337
|
+
const tabs = document.querySelectorAll('.nav-tab');
|
|
338
|
+
const views = document.querySelectorAll('.view-container');
|
|
339
|
+
const filterBar = document.getElementById('filter-bar');
|
|
340
|
+
|
|
341
|
+
tabs.forEach(function (tab) {
|
|
342
|
+
tab.addEventListener('click', function () {
|
|
343
|
+
const targetView = tab.getAttribute('data-view');
|
|
344
|
+
|
|
345
|
+
// Update active tab
|
|
346
|
+
tabs.forEach(function (t) { t.classList.remove('active'); });
|
|
347
|
+
tab.classList.add('active');
|
|
348
|
+
|
|
349
|
+
// Show target view, hide others
|
|
350
|
+
views.forEach(function (v) {
|
|
351
|
+
v.classList.toggle('active', v.id === targetView);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Show/hide filter bar and time range bar (only for graph view)
|
|
355
|
+
var isGraph = targetView === 'graph-view';
|
|
356
|
+
var mainContent = document.getElementById('main-content');
|
|
357
|
+
if (filterBar) {
|
|
358
|
+
filterBar.style.display = isGraph ? '' : 'none';
|
|
359
|
+
}
|
|
360
|
+
var timeRangeBar = document.getElementById('time-range-bar');
|
|
361
|
+
if (timeRangeBar) {
|
|
362
|
+
timeRangeBar.style.display = isGraph ? '' : 'none';
|
|
363
|
+
}
|
|
364
|
+
if (mainContent) {
|
|
365
|
+
mainContent.classList.toggle('no-bars', !isGraph);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Refresh stats when switching to settings tab
|
|
369
|
+
if (targetView === 'settings-view' && window.laminarkSettings) {
|
|
370
|
+
window.laminarkSettings.refreshStats();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Lazy initialization: only init each view when first activated
|
|
374
|
+
if (targetView === 'timeline-view' && !window.laminarkState.timelineInitialized) {
|
|
375
|
+
if (window.laminarkTimeline) {
|
|
376
|
+
window.laminarkTimeline.initTimeline('timeline-view');
|
|
377
|
+
window.laminarkTimeline.loadTimelineData();
|
|
378
|
+
window.laminarkState.timelineInitialized = true;
|
|
379
|
+
}
|
|
380
|
+
} else if (targetView === 'graph-view' && !window.laminarkState.graphInitialized) {
|
|
381
|
+
if (window.laminarkGraph) {
|
|
382
|
+
window.laminarkGraph.initGraph('cy');
|
|
383
|
+
window.laminarkGraph.loadGraphData();
|
|
384
|
+
window.laminarkState.graphInitialized = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Filter bar
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
function initFilters() {
|
|
396
|
+
const pills = document.querySelectorAll('.filter-pill');
|
|
397
|
+
|
|
398
|
+
pills.forEach(function (pill) {
|
|
399
|
+
pill.addEventListener('click', function () {
|
|
400
|
+
const type = pill.getAttribute('data-type');
|
|
401
|
+
|
|
402
|
+
if (type === 'all') {
|
|
403
|
+
var typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
|
|
404
|
+
var allCurrentlyActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
|
|
405
|
+
|
|
406
|
+
if (allCurrentlyActive) {
|
|
407
|
+
// Toggle off -- deactivate all pills
|
|
408
|
+
pills.forEach(function (p) {
|
|
409
|
+
p.classList.remove('active');
|
|
410
|
+
});
|
|
411
|
+
if (window.laminarkGraph && window.laminarkGraph.setActiveTypes) {
|
|
412
|
+
window.laminarkGraph.setActiveTypes([]);
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
// Toggle on -- activate all type pills
|
|
416
|
+
pills.forEach(function (p) {
|
|
417
|
+
p.classList.add('active');
|
|
418
|
+
});
|
|
419
|
+
if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
|
|
420
|
+
window.laminarkGraph.resetFilters();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// Toggle this filter pill
|
|
425
|
+
pill.classList.toggle('active');
|
|
426
|
+
|
|
427
|
+
// Check if all type pills are now active
|
|
428
|
+
const typePills = document.querySelectorAll('.filter-pill:not([data-type="all"])');
|
|
429
|
+
const allActive = Array.from(typePills).every(function (p) { return p.classList.contains('active'); });
|
|
430
|
+
const noneActive = !Array.from(typePills).some(function (p) { return p.classList.contains('active'); });
|
|
431
|
+
const allPill = document.querySelector('.filter-pill[data-type="all"]');
|
|
432
|
+
|
|
433
|
+
if (allActive || noneActive) {
|
|
434
|
+
// All selected or none selected -- reset to "All"
|
|
435
|
+
pills.forEach(function (p) { p.classList.add('active'); });
|
|
436
|
+
if (window.laminarkGraph && window.laminarkGraph.resetFilters) {
|
|
437
|
+
window.laminarkGraph.resetFilters();
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Update "All" pill state
|
|
443
|
+
if (allPill) allPill.classList.remove('active');
|
|
444
|
+
|
|
445
|
+
// Use graph.js filterByType for toggle behavior
|
|
446
|
+
if (window.laminarkGraph && window.laminarkGraph.filterByType) {
|
|
447
|
+
window.laminarkGraph.filterByType(type);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Dispatch filter change event for any other listeners
|
|
452
|
+
const activeTypes = getActiveFilters();
|
|
453
|
+
document.dispatchEvent(new CustomEvent('laminark:filter_change', { detail: { types: activeTypes } }));
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function getActiveFilters() {
|
|
459
|
+
const allPill = document.querySelector('.filter-pill[data-type="all"].active');
|
|
460
|
+
if (allPill) return null; // No filter -- show all
|
|
461
|
+
|
|
462
|
+
const active = document.querySelectorAll('.filter-pill.active');
|
|
463
|
+
return Array.from(active).map(function (p) { return p.getAttribute('data-type'); });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Time range controls
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
function initTimeRange() {
|
|
471
|
+
var presetBtns = document.querySelectorAll('.time-preset');
|
|
472
|
+
var timeFromInput = document.getElementById('time-from');
|
|
473
|
+
var timeToInput = document.getElementById('time-to');
|
|
474
|
+
var applyBtn = document.getElementById('time-apply');
|
|
475
|
+
|
|
476
|
+
presetBtns.forEach(function (btn) {
|
|
477
|
+
btn.addEventListener('click', function () {
|
|
478
|
+
var preset = btn.getAttribute('data-preset');
|
|
479
|
+
|
|
480
|
+
// Update active state
|
|
481
|
+
presetBtns.forEach(function (b) { b.classList.remove('active'); });
|
|
482
|
+
btn.classList.add('active');
|
|
483
|
+
|
|
484
|
+
// Calculate from/to dates based on preset
|
|
485
|
+
var now = new Date();
|
|
486
|
+
var from = null;
|
|
487
|
+
var to = null;
|
|
488
|
+
|
|
489
|
+
if (preset === 'hour') {
|
|
490
|
+
from = new Date(now.getTime() - 60 * 60 * 1000);
|
|
491
|
+
to = now;
|
|
492
|
+
} else if (preset === 'today') {
|
|
493
|
+
from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
494
|
+
to = now;
|
|
495
|
+
} else if (preset === 'week') {
|
|
496
|
+
// Start of this week (Monday)
|
|
497
|
+
var day = now.getDay();
|
|
498
|
+
var diff = day === 0 ? 6 : day - 1; // Monday = 0 offset
|
|
499
|
+
from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff);
|
|
500
|
+
to = now;
|
|
501
|
+
} else if (preset === 'month') {
|
|
502
|
+
from = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
503
|
+
to = now;
|
|
504
|
+
}
|
|
505
|
+
// preset === 'all' => from = null, to = null
|
|
506
|
+
|
|
507
|
+
// Update the date inputs to reflect the preset
|
|
508
|
+
if (timeFromInput && timeToInput) {
|
|
509
|
+
timeFromInput.value = from ? toDatetimeLocalString(from) : '';
|
|
510
|
+
timeToInput.value = to ? toDatetimeLocalString(to) : '';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Apply client-side time range filter (instant for presets)
|
|
514
|
+
if (window.laminarkGraph && window.laminarkGraph.filterByTimeRange) {
|
|
515
|
+
window.laminarkGraph.filterByTimeRange(
|
|
516
|
+
from ? from.toISOString() : null,
|
|
517
|
+
to ? to.toISOString() : null
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Custom date range -- apply button
|
|
524
|
+
if (applyBtn) {
|
|
525
|
+
applyBtn.addEventListener('click', function () {
|
|
526
|
+
// Deactivate preset buttons
|
|
527
|
+
presetBtns.forEach(function (b) { b.classList.remove('active'); });
|
|
528
|
+
|
|
529
|
+
var fromVal = timeFromInput ? timeFromInput.value : '';
|
|
530
|
+
var toVal = timeToInput ? timeToInput.value : '';
|
|
531
|
+
|
|
532
|
+
var from = fromVal ? new Date(fromVal).toISOString() : null;
|
|
533
|
+
var to = toVal ? new Date(toVal).toISOString() : null;
|
|
534
|
+
|
|
535
|
+
// For custom date ranges, re-fetch from API for server-side filtering
|
|
536
|
+
if (window.laminarkGraph) {
|
|
537
|
+
var filters = {};
|
|
538
|
+
if (from) filters.since = from;
|
|
539
|
+
if (to) filters.until = to;
|
|
540
|
+
|
|
541
|
+
// Re-fetch graph data with server-side filtering
|
|
542
|
+
window.laminarkGraph.loadGraphData(filters).then(function () {
|
|
543
|
+
// Then also apply client-side time range for combined filtering
|
|
544
|
+
if (window.laminarkGraph.filterByTimeRange) {
|
|
545
|
+
window.laminarkGraph.filterByTimeRange(from, to);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Converts a Date to a datetime-local input value string.
|
|
555
|
+
* @param {Date} date
|
|
556
|
+
* @returns {string}
|
|
557
|
+
*/
|
|
558
|
+
function toDatetimeLocalString(date) {
|
|
559
|
+
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
560
|
+
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
|
|
561
|
+
'T' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Detail panel
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
function initDetailPanel() {
|
|
569
|
+
const closeBtn = document.getElementById('detail-close');
|
|
570
|
+
const focusBtn = document.getElementById('detail-focus');
|
|
571
|
+
|
|
572
|
+
if (closeBtn) {
|
|
573
|
+
closeBtn.addEventListener('click', function () {
|
|
574
|
+
if (window.laminarkGraph && window.laminarkGraph.hideDetailPanel) {
|
|
575
|
+
window.laminarkGraph.hideDetailPanel();
|
|
576
|
+
} else {
|
|
577
|
+
var panel = document.getElementById('detail-panel');
|
|
578
|
+
if (panel) panel.classList.add('hidden');
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (focusBtn) {
|
|
584
|
+
focusBtn.addEventListener('click', function () {
|
|
585
|
+
var nodeId = focusBtn.getAttribute('data-node-id');
|
|
586
|
+
var nodeLabel = focusBtn.getAttribute('data-node-label');
|
|
587
|
+
if (nodeId && window.laminarkGraph && window.laminarkGraph.enterFocusMode) {
|
|
588
|
+
window.laminarkGraph.enterFocusMode(nodeId, nodeLabel || nodeId);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function showNodeDetails(nodeData) {
|
|
595
|
+
const panel = document.getElementById('detail-panel');
|
|
596
|
+
const title = document.getElementById('detail-title');
|
|
597
|
+
const body = document.getElementById('detail-body');
|
|
598
|
+
|
|
599
|
+
if (!panel || !title || !body) return;
|
|
600
|
+
|
|
601
|
+
title.textContent = nodeData.entity.label;
|
|
602
|
+
|
|
603
|
+
// Update focus button with current node info
|
|
604
|
+
var focusBtn = document.getElementById('detail-focus');
|
|
605
|
+
if (focusBtn) {
|
|
606
|
+
focusBtn.setAttribute('data-node-id', nodeData.entity.id);
|
|
607
|
+
focusBtn.setAttribute('data-node-label', nodeData.entity.label);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Build detail panel content using DOM elements for safety
|
|
611
|
+
body.innerHTML = '';
|
|
612
|
+
|
|
613
|
+
// Entity info section
|
|
614
|
+
var entitySection = document.createElement('div');
|
|
615
|
+
entitySection.className = 'detail-section';
|
|
616
|
+
|
|
617
|
+
var entityTitle = document.createElement('div');
|
|
618
|
+
entityTitle.className = 'detail-section-title';
|
|
619
|
+
entityTitle.textContent = 'Entity';
|
|
620
|
+
entitySection.appendChild(entityTitle);
|
|
621
|
+
|
|
622
|
+
// Type field with colored badge
|
|
623
|
+
var typeField = document.createElement('div');
|
|
624
|
+
typeField.className = 'detail-field';
|
|
625
|
+
var typeLabel = document.createElement('span');
|
|
626
|
+
typeLabel.className = 'field-label';
|
|
627
|
+
typeLabel.textContent = 'Type: ';
|
|
628
|
+
var typeBadge = document.createElement('span');
|
|
629
|
+
typeBadge.className = 'type-badge';
|
|
630
|
+
typeBadge.setAttribute('data-type', nodeData.entity.type);
|
|
631
|
+
typeBadge.textContent = nodeData.entity.type;
|
|
632
|
+
typeField.appendChild(typeLabel);
|
|
633
|
+
typeField.appendChild(typeBadge);
|
|
634
|
+
entitySection.appendChild(typeField);
|
|
635
|
+
|
|
636
|
+
// Created date
|
|
637
|
+
var createdField = document.createElement('div');
|
|
638
|
+
createdField.className = 'detail-field';
|
|
639
|
+
var createdLabel = document.createElement('span');
|
|
640
|
+
createdLabel.className = 'field-label';
|
|
641
|
+
createdLabel.textContent = 'Created: ';
|
|
642
|
+
var createdValue = document.createElement('span');
|
|
643
|
+
createdValue.className = 'field-value';
|
|
644
|
+
createdValue.textContent = formatTime(nodeData.entity.createdAt);
|
|
645
|
+
createdField.appendChild(createdLabel);
|
|
646
|
+
createdField.appendChild(createdValue);
|
|
647
|
+
entitySection.appendChild(createdField);
|
|
648
|
+
|
|
649
|
+
body.appendChild(entitySection);
|
|
650
|
+
|
|
651
|
+
// Observations section -- scrollable list sorted most recent first
|
|
652
|
+
if (nodeData.observations && nodeData.observations.length > 0) {
|
|
653
|
+
var obsSection = document.createElement('div');
|
|
654
|
+
obsSection.className = 'detail-section';
|
|
655
|
+
|
|
656
|
+
var obsTitle = document.createElement('div');
|
|
657
|
+
obsTitle.className = 'detail-section-title';
|
|
658
|
+
obsTitle.textContent = 'Observations (' + nodeData.observations.length + ')';
|
|
659
|
+
obsSection.appendChild(obsTitle);
|
|
660
|
+
|
|
661
|
+
var obsList = document.createElement('div');
|
|
662
|
+
obsList.className = 'observation-list-panel';
|
|
663
|
+
|
|
664
|
+
nodeData.observations.forEach(function (obs) {
|
|
665
|
+
var item = document.createElement('div');
|
|
666
|
+
item.className = 'observation-item';
|
|
667
|
+
|
|
668
|
+
var timestamp = document.createElement('span');
|
|
669
|
+
timestamp.className = 'obs-timestamp';
|
|
670
|
+
timestamp.textContent = formatTime(obs.createdAt);
|
|
671
|
+
item.appendChild(timestamp);
|
|
672
|
+
|
|
673
|
+
var text = document.createElement('span');
|
|
674
|
+
text.className = 'obs-text-content';
|
|
675
|
+
text.textContent = obs.text.length > 200 ? obs.text.substring(0, 200) + '...' : obs.text;
|
|
676
|
+
item.appendChild(text);
|
|
677
|
+
|
|
678
|
+
obsList.appendChild(item);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
obsSection.appendChild(obsList);
|
|
682
|
+
body.appendChild(obsSection);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Relationships section -- each clickable to navigate to that node
|
|
686
|
+
if (nodeData.relationships && nodeData.relationships.length > 0) {
|
|
687
|
+
var relSection = document.createElement('div');
|
|
688
|
+
relSection.className = 'detail-section';
|
|
689
|
+
|
|
690
|
+
var relTitle = document.createElement('div');
|
|
691
|
+
relTitle.className = 'detail-section-title';
|
|
692
|
+
relTitle.textContent = 'Relationships (' + nodeData.relationships.length + ')';
|
|
693
|
+
relSection.appendChild(relTitle);
|
|
694
|
+
|
|
695
|
+
nodeData.relationships.forEach(function (rel) {
|
|
696
|
+
var item = document.createElement('div');
|
|
697
|
+
item.className = 'relationship-item';
|
|
698
|
+
item.setAttribute('data-target-id', rel.targetId);
|
|
699
|
+
item.style.cursor = 'pointer';
|
|
700
|
+
|
|
701
|
+
var relType = document.createElement('span');
|
|
702
|
+
relType.className = 'rel-type';
|
|
703
|
+
relType.textContent = rel.type;
|
|
704
|
+
item.appendChild(relType);
|
|
705
|
+
|
|
706
|
+
var arrow = document.createElement('span');
|
|
707
|
+
arrow.className = 'rel-arrow';
|
|
708
|
+
arrow.textContent = rel.direction === 'outgoing' ? ' \u2192 ' : ' \u2190 ';
|
|
709
|
+
item.appendChild(arrow);
|
|
710
|
+
|
|
711
|
+
var target = document.createElement('span');
|
|
712
|
+
target.className = 'rel-target';
|
|
713
|
+
target.textContent = rel.targetLabel;
|
|
714
|
+
item.appendChild(target);
|
|
715
|
+
|
|
716
|
+
// Click to navigate to the related node in the graph
|
|
717
|
+
item.addEventListener('click', function () {
|
|
718
|
+
if (window.laminarkGraph && window.laminarkGraph.selectAndCenterNode) {
|
|
719
|
+
window.laminarkGraph.selectAndCenterNode(rel.targetId);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
relSection.appendChild(item);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
body.appendChild(relSection);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (!nodeData.observations?.length && !nodeData.relationships?.length) {
|
|
730
|
+
var emptyMsg = document.createElement('p');
|
|
731
|
+
emptyMsg.className = 'empty-state';
|
|
732
|
+
emptyMsg.textContent = 'No details available for this node.';
|
|
733
|
+
body.appendChild(emptyMsg);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
panel.classList.remove('hidden');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function showPathDetails(pathData) {
|
|
740
|
+
var panel = document.getElementById('path-detail-panel');
|
|
741
|
+
var title = document.getElementById('path-detail-title');
|
|
742
|
+
var body = document.getElementById('path-detail-body');
|
|
743
|
+
|
|
744
|
+
if (!panel || !title || !body) return;
|
|
745
|
+
|
|
746
|
+
var path = pathData.path;
|
|
747
|
+
var waypoints = pathData.waypoints || [];
|
|
748
|
+
|
|
749
|
+
title.textContent = 'Debug Path';
|
|
750
|
+
body.innerHTML = '';
|
|
751
|
+
|
|
752
|
+
// Status + trigger section
|
|
753
|
+
var infoSection = document.createElement('div');
|
|
754
|
+
infoSection.className = 'path-info-section';
|
|
755
|
+
|
|
756
|
+
var statusBadge = document.createElement('span');
|
|
757
|
+
statusBadge.className = 'path-status-badge ' + path.status;
|
|
758
|
+
statusBadge.textContent = path.status;
|
|
759
|
+
infoSection.appendChild(statusBadge);
|
|
760
|
+
|
|
761
|
+
var triggerLabel = document.createElement('div');
|
|
762
|
+
triggerLabel.className = 'path-info-label';
|
|
763
|
+
triggerLabel.style.marginTop = '8px';
|
|
764
|
+
triggerLabel.textContent = 'Trigger';
|
|
765
|
+
infoSection.appendChild(triggerLabel);
|
|
766
|
+
|
|
767
|
+
var triggerValue = document.createElement('div');
|
|
768
|
+
triggerValue.className = 'path-info-value';
|
|
769
|
+
triggerValue.textContent = path.trigger_summary || 'Unknown trigger';
|
|
770
|
+
infoSection.appendChild(triggerValue);
|
|
771
|
+
|
|
772
|
+
// Started at
|
|
773
|
+
var startedLabel = document.createElement('div');
|
|
774
|
+
startedLabel.className = 'path-info-label';
|
|
775
|
+
startedLabel.style.marginTop = '6px';
|
|
776
|
+
startedLabel.textContent = 'Started';
|
|
777
|
+
infoSection.appendChild(startedLabel);
|
|
778
|
+
|
|
779
|
+
var startedValue = document.createElement('div');
|
|
780
|
+
startedValue.className = 'path-info-value';
|
|
781
|
+
startedValue.textContent = formatTime(path.started_at);
|
|
782
|
+
infoSection.appendChild(startedValue);
|
|
783
|
+
|
|
784
|
+
// Resolved at (if resolved)
|
|
785
|
+
if (path.resolved_at) {
|
|
786
|
+
var resolvedLabel = document.createElement('div');
|
|
787
|
+
resolvedLabel.className = 'path-info-label';
|
|
788
|
+
resolvedLabel.style.marginTop = '6px';
|
|
789
|
+
resolvedLabel.textContent = 'Resolved';
|
|
790
|
+
infoSection.appendChild(resolvedLabel);
|
|
791
|
+
|
|
792
|
+
var resolvedValue = document.createElement('div');
|
|
793
|
+
resolvedValue.className = 'path-info-value';
|
|
794
|
+
resolvedValue.textContent = formatTime(path.resolved_at);
|
|
795
|
+
infoSection.appendChild(resolvedValue);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Resolution summary
|
|
799
|
+
if (path.resolution_summary) {
|
|
800
|
+
var resLabel = document.createElement('div');
|
|
801
|
+
resLabel.className = 'path-info-label';
|
|
802
|
+
resLabel.style.marginTop = '6px';
|
|
803
|
+
resLabel.textContent = 'Resolution';
|
|
804
|
+
infoSection.appendChild(resLabel);
|
|
805
|
+
|
|
806
|
+
var resValue = document.createElement('div');
|
|
807
|
+
resValue.className = 'path-info-value';
|
|
808
|
+
resValue.textContent = path.resolution_summary;
|
|
809
|
+
infoSection.appendChild(resValue);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
body.appendChild(infoSection);
|
|
813
|
+
|
|
814
|
+
// KISS Summary (if present)
|
|
815
|
+
if (path.kiss_summary) {
|
|
816
|
+
var kiss;
|
|
817
|
+
try {
|
|
818
|
+
kiss = typeof path.kiss_summary === 'string' ? JSON.parse(path.kiss_summary) : path.kiss_summary;
|
|
819
|
+
} catch (e) { kiss = null; }
|
|
820
|
+
|
|
821
|
+
if (kiss) {
|
|
822
|
+
var kissSection = document.createElement('div');
|
|
823
|
+
kissSection.className = 'kiss-summary-section';
|
|
824
|
+
|
|
825
|
+
var kissTitle = document.createElement('div');
|
|
826
|
+
kissTitle.className = 'kiss-summary-title';
|
|
827
|
+
kissTitle.textContent = 'KISS Summary';
|
|
828
|
+
kissSection.appendChild(kissTitle);
|
|
829
|
+
|
|
830
|
+
var kissFields = [
|
|
831
|
+
{ label: 'Problem', value: kiss.problem },
|
|
832
|
+
{ label: 'Cause', value: kiss.cause },
|
|
833
|
+
{ label: 'Fix', value: kiss.fix },
|
|
834
|
+
{ label: 'Prevention', value: kiss.prevention },
|
|
835
|
+
];
|
|
836
|
+
|
|
837
|
+
kissFields.forEach(function(field) {
|
|
838
|
+
if (!field.value) return;
|
|
839
|
+
var fieldDiv = document.createElement('div');
|
|
840
|
+
fieldDiv.className = 'kiss-summary-field';
|
|
841
|
+
|
|
842
|
+
var fieldLabel = document.createElement('div');
|
|
843
|
+
fieldLabel.className = 'kiss-summary-field-label';
|
|
844
|
+
fieldLabel.textContent = field.label;
|
|
845
|
+
fieldDiv.appendChild(fieldLabel);
|
|
846
|
+
|
|
847
|
+
var fieldValue = document.createElement('div');
|
|
848
|
+
fieldValue.className = 'kiss-summary-field-value';
|
|
849
|
+
fieldValue.textContent = field.value;
|
|
850
|
+
fieldDiv.appendChild(fieldValue);
|
|
851
|
+
|
|
852
|
+
kissSection.appendChild(fieldDiv);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
body.appendChild(kissSection);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Waypoint timeline
|
|
860
|
+
if (waypoints.length > 0) {
|
|
861
|
+
var wpSectionTitle = document.createElement('div');
|
|
862
|
+
wpSectionTitle.className = 'waypoint-section-title';
|
|
863
|
+
wpSectionTitle.textContent = 'Waypoints (' + waypoints.length + ')';
|
|
864
|
+
body.appendChild(wpSectionTitle);
|
|
865
|
+
|
|
866
|
+
var timeline = document.createElement('div');
|
|
867
|
+
timeline.className = 'waypoint-timeline';
|
|
868
|
+
|
|
869
|
+
waypoints.forEach(function(wp) {
|
|
870
|
+
var item = document.createElement('div');
|
|
871
|
+
item.className = 'waypoint-item';
|
|
872
|
+
item.setAttribute('data-type', wp.waypoint_type);
|
|
873
|
+
|
|
874
|
+
var header = document.createElement('div');
|
|
875
|
+
header.className = 'waypoint-header';
|
|
876
|
+
|
|
877
|
+
var seq = document.createElement('span');
|
|
878
|
+
seq.className = 'waypoint-seq';
|
|
879
|
+
seq.textContent = '#' + wp.sequence_order;
|
|
880
|
+
header.appendChild(seq);
|
|
881
|
+
|
|
882
|
+
var typeLabel = document.createElement('span');
|
|
883
|
+
typeLabel.className = 'waypoint-type-label';
|
|
884
|
+
var WAYPOINT_COLORS = {
|
|
885
|
+
error: '#f85149', attempt: '#d29922', failure: '#f0883e',
|
|
886
|
+
success: '#3fb950', pivot: '#a371f7', revert: '#79c0ff',
|
|
887
|
+
discovery: '#58a6ff', resolution: '#3fb950'
|
|
888
|
+
};
|
|
889
|
+
typeLabel.style.color = WAYPOINT_COLORS[wp.waypoint_type] || '#8b949e';
|
|
890
|
+
typeLabel.textContent = wp.waypoint_type;
|
|
891
|
+
header.appendChild(typeLabel);
|
|
892
|
+
|
|
893
|
+
var time = document.createElement('span');
|
|
894
|
+
time.className = 'waypoint-time';
|
|
895
|
+
time.textContent = formatTime(wp.created_at);
|
|
896
|
+
header.appendChild(time);
|
|
897
|
+
|
|
898
|
+
item.appendChild(header);
|
|
899
|
+
|
|
900
|
+
if (wp.summary) {
|
|
901
|
+
var summary = document.createElement('div');
|
|
902
|
+
summary.className = 'waypoint-summary';
|
|
903
|
+
summary.textContent = wp.summary;
|
|
904
|
+
item.appendChild(summary);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
timeline.appendChild(item);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
body.appendChild(timeline);
|
|
911
|
+
} else {
|
|
912
|
+
var emptyMsg = document.createElement('p');
|
|
913
|
+
emptyMsg.className = 'empty-state';
|
|
914
|
+
emptyMsg.textContent = 'No waypoints recorded yet.';
|
|
915
|
+
body.appendChild(emptyMsg);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Hide the node detail panel if it's open (avoid two panels)
|
|
919
|
+
var nodePanel = document.getElementById('detail-panel');
|
|
920
|
+
if (nodePanel) nodePanel.classList.add('hidden');
|
|
921
|
+
|
|
922
|
+
panel.classList.remove('hidden');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function initPathDetailPanel() {
|
|
926
|
+
var closeBtn = document.getElementById('path-detail-close');
|
|
927
|
+
if (closeBtn) {
|
|
928
|
+
closeBtn.addEventListener('click', function() {
|
|
929
|
+
var panel = document.getElementById('path-detail-panel');
|
|
930
|
+
if (panel) panel.classList.add('hidden');
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Listen for path detail events from graph overlay clicks
|
|
935
|
+
document.addEventListener('laminark:show_path_detail', function(e) {
|
|
936
|
+
if (e.detail) {
|
|
937
|
+
showPathDetails(e.detail);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function escapeHtml(text) {
|
|
943
|
+
const div = document.createElement('div');
|
|
944
|
+
div.textContent = text;
|
|
945
|
+
return div.innerHTML;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
// Graph Search
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
function initSearch() {
|
|
953
|
+
var searchInput = document.getElementById('graph-search');
|
|
954
|
+
var dropdown = document.getElementById('search-results');
|
|
955
|
+
if (!searchInput || !dropdown) return;
|
|
956
|
+
|
|
957
|
+
var debounceTimer = null;
|
|
958
|
+
var activeIndex = -1;
|
|
959
|
+
var currentResults = [];
|
|
960
|
+
|
|
961
|
+
function renderDropdown(results) {
|
|
962
|
+
currentResults = results;
|
|
963
|
+
activeIndex = -1;
|
|
964
|
+
|
|
965
|
+
if (results.length === 0 && searchInput.value.trim().length > 0) {
|
|
966
|
+
dropdown.innerHTML = '<div class="search-no-results">No matches found</div>';
|
|
967
|
+
dropdown.classList.remove('hidden');
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (results.length === 0) {
|
|
972
|
+
dropdown.classList.add('hidden');
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
dropdown.innerHTML = '';
|
|
977
|
+
results.forEach(function (r, idx) {
|
|
978
|
+
var item = document.createElement('div');
|
|
979
|
+
item.className = 'search-result-item';
|
|
980
|
+
item.setAttribute('data-index', idx);
|
|
981
|
+
|
|
982
|
+
var dot = document.createElement('span');
|
|
983
|
+
dot.className = 'search-result-dot';
|
|
984
|
+
var style = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES[r.type]) || { color: '#8b949e' };
|
|
985
|
+
dot.style.background = style.color;
|
|
986
|
+
item.appendChild(dot);
|
|
987
|
+
|
|
988
|
+
var labelWrap = document.createElement('div');
|
|
989
|
+
labelWrap.style.flex = '1';
|
|
990
|
+
labelWrap.style.minWidth = '0';
|
|
991
|
+
|
|
992
|
+
var label = document.createElement('div');
|
|
993
|
+
label.className = 'search-result-label';
|
|
994
|
+
label.textContent = r.label;
|
|
995
|
+
labelWrap.appendChild(label);
|
|
996
|
+
|
|
997
|
+
if (r.snippet) {
|
|
998
|
+
var snippet = document.createElement('div');
|
|
999
|
+
snippet.className = 'search-result-snippet';
|
|
1000
|
+
snippet.textContent = r.snippet;
|
|
1001
|
+
labelWrap.appendChild(snippet);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
item.appendChild(labelWrap);
|
|
1005
|
+
|
|
1006
|
+
var typeBadge = document.createElement('span');
|
|
1007
|
+
typeBadge.className = 'search-result-type';
|
|
1008
|
+
typeBadge.textContent = r.type;
|
|
1009
|
+
item.appendChild(typeBadge);
|
|
1010
|
+
|
|
1011
|
+
if (r.matchSource) {
|
|
1012
|
+
var src = document.createElement('span');
|
|
1013
|
+
src.className = 'search-result-source';
|
|
1014
|
+
src.textContent = r.matchSource;
|
|
1015
|
+
item.appendChild(src);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
item.addEventListener('click', function () {
|
|
1019
|
+
selectResult(r);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
dropdown.appendChild(item);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
dropdown.classList.remove('hidden');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function selectResult(r) {
|
|
1029
|
+
dropdown.classList.add('hidden');
|
|
1030
|
+
searchInput.value = r.label;
|
|
1031
|
+
|
|
1032
|
+
if (window.laminarkGraph) {
|
|
1033
|
+
window.laminarkGraph.selectAndCenterNode(r.id);
|
|
1034
|
+
window.laminarkGraph.highlightSearchMatches([r.id]);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function clearSearch() {
|
|
1039
|
+
searchInput.value = '';
|
|
1040
|
+
dropdown.classList.add('hidden');
|
|
1041
|
+
currentResults = [];
|
|
1042
|
+
activeIndex = -1;
|
|
1043
|
+
if (window.laminarkGraph) {
|
|
1044
|
+
window.laminarkGraph.clearSearchHighlight();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Debounced client-side search on keyup
|
|
1049
|
+
searchInput.addEventListener('input', function () {
|
|
1050
|
+
var query = searchInput.value.trim();
|
|
1051
|
+
|
|
1052
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1053
|
+
|
|
1054
|
+
if (!query) {
|
|
1055
|
+
clearSearch();
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
debounceTimer = setTimeout(function () {
|
|
1060
|
+
// Client-side search on loaded Cytoscape data
|
|
1061
|
+
var results = [];
|
|
1062
|
+
if (window.laminarkGraph && window.laminarkGraph.searchNodes) {
|
|
1063
|
+
var clientResults = window.laminarkGraph.searchNodes(query);
|
|
1064
|
+
results = clientResults.map(function (r) {
|
|
1065
|
+
return {
|
|
1066
|
+
id: r.id,
|
|
1067
|
+
label: r.label,
|
|
1068
|
+
type: r.type,
|
|
1069
|
+
matchSource: null,
|
|
1070
|
+
snippet: null,
|
|
1071
|
+
};
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Highlight matching nodes in graph
|
|
1076
|
+
if (results.length > 0 && window.laminarkGraph) {
|
|
1077
|
+
window.laminarkGraph.highlightSearchMatches(results.map(function (r) { return r.id; }));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
renderDropdown(results);
|
|
1081
|
+
}, 250);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// Enter triggers server-side search
|
|
1085
|
+
searchInput.addEventListener('keydown', function (e) {
|
|
1086
|
+
if (e.key === 'Enter') {
|
|
1087
|
+
e.preventDefault();
|
|
1088
|
+
var query = searchInput.value.trim();
|
|
1089
|
+
if (!query) return;
|
|
1090
|
+
|
|
1091
|
+
var params = new URLSearchParams({ q: query, limit: '20' });
|
|
1092
|
+
if (window.laminarkState.currentProject) {
|
|
1093
|
+
params.set('project', window.laminarkState.currentProject);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
fetch('/api/graph/search?' + params.toString())
|
|
1097
|
+
.then(function (res) { return res.json(); })
|
|
1098
|
+
.then(function (data) {
|
|
1099
|
+
renderDropdown(data.results || []);
|
|
1100
|
+
|
|
1101
|
+
// Highlight all matching nodes in graph
|
|
1102
|
+
if (data.results && data.results.length > 0 && window.laminarkGraph) {
|
|
1103
|
+
window.laminarkGraph.highlightSearchMatches(
|
|
1104
|
+
data.results.map(function (r) { return r.id; })
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
})
|
|
1108
|
+
.catch(function (err) {
|
|
1109
|
+
console.error('[laminark] Search failed:', err);
|
|
1110
|
+
});
|
|
1111
|
+
} else if (e.key === 'Escape') {
|
|
1112
|
+
clearSearch();
|
|
1113
|
+
} else if (e.key === 'ArrowDown') {
|
|
1114
|
+
e.preventDefault();
|
|
1115
|
+
if (currentResults.length > 0) {
|
|
1116
|
+
activeIndex = Math.min(activeIndex + 1, currentResults.length - 1);
|
|
1117
|
+
updateActiveItem();
|
|
1118
|
+
}
|
|
1119
|
+
} else if (e.key === 'ArrowUp') {
|
|
1120
|
+
e.preventDefault();
|
|
1121
|
+
if (currentResults.length > 0) {
|
|
1122
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
1123
|
+
updateActiveItem();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Enter on active dropdown item
|
|
1128
|
+
if (e.key === 'Enter' && activeIndex >= 0 && currentResults[activeIndex]) {
|
|
1129
|
+
selectResult(currentResults[activeIndex]);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
function updateActiveItem() {
|
|
1134
|
+
var items = dropdown.querySelectorAll('.search-result-item');
|
|
1135
|
+
items.forEach(function (item, idx) {
|
|
1136
|
+
item.classList.toggle('active', idx === activeIndex);
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Close dropdown on outside click
|
|
1141
|
+
document.addEventListener('click', function (e) {
|
|
1142
|
+
if (!searchInput.contains(e.target) && !dropdown.contains(e.target)) {
|
|
1143
|
+
dropdown.classList.add('hidden');
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ---------------------------------------------------------------------------
|
|
1149
|
+
// Graph Analysis Panel
|
|
1150
|
+
// ---------------------------------------------------------------------------
|
|
1151
|
+
|
|
1152
|
+
var analysisOpen = false;
|
|
1153
|
+
|
|
1154
|
+
function initAnalysis() {
|
|
1155
|
+
var btn = document.getElementById('analysis-btn');
|
|
1156
|
+
var panel = document.getElementById('analysis-panel');
|
|
1157
|
+
var closeBtn = document.getElementById('analysis-close');
|
|
1158
|
+
if (!btn || !panel) return;
|
|
1159
|
+
|
|
1160
|
+
btn.addEventListener('click', function () {
|
|
1161
|
+
analysisOpen = !analysisOpen;
|
|
1162
|
+
btn.classList.toggle('active', analysisOpen);
|
|
1163
|
+
if (analysisOpen) {
|
|
1164
|
+
panel.classList.remove('hidden');
|
|
1165
|
+
loadAnalysis();
|
|
1166
|
+
} else {
|
|
1167
|
+
panel.classList.add('hidden');
|
|
1168
|
+
// Clear any cluster highlights
|
|
1169
|
+
if (window.laminarkGraph) {
|
|
1170
|
+
window.laminarkGraph.clearSearchHighlight();
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// Let CSS transition finish, then refit graph to new width
|
|
1174
|
+
setTimeout(function () {
|
|
1175
|
+
if (window.laminarkGraph) window.laminarkGraph.fitToView();
|
|
1176
|
+
}, 350);
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
if (closeBtn) {
|
|
1180
|
+
closeBtn.addEventListener('click', function () {
|
|
1181
|
+
analysisOpen = false;
|
|
1182
|
+
panel.classList.add('hidden');
|
|
1183
|
+
btn.classList.remove('active');
|
|
1184
|
+
if (window.laminarkGraph) {
|
|
1185
|
+
window.laminarkGraph.clearSearchHighlight();
|
|
1186
|
+
}
|
|
1187
|
+
setTimeout(function () {
|
|
1188
|
+
if (window.laminarkGraph) window.laminarkGraph.fitToView();
|
|
1189
|
+
}, 350);
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function loadAnalysis() {
|
|
1195
|
+
var body = document.getElementById('analysis-body');
|
|
1196
|
+
if (!body) return;
|
|
1197
|
+
|
|
1198
|
+
body.innerHTML = '<p class="empty-state">Loading analysis...</p>';
|
|
1199
|
+
|
|
1200
|
+
var params = new URLSearchParams();
|
|
1201
|
+
if (window.laminarkState.currentProject) {
|
|
1202
|
+
params.set('project', window.laminarkState.currentProject);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
fetch('/api/graph/analysis' + (params.toString() ? '?' + params.toString() : ''))
|
|
1206
|
+
.then(function (res) { return res.json(); })
|
|
1207
|
+
.then(function (data) {
|
|
1208
|
+
renderAnalysis(body, data);
|
|
1209
|
+
})
|
|
1210
|
+
.catch(function (err) {
|
|
1211
|
+
console.error('[laminark] Analysis failed:', err);
|
|
1212
|
+
body.innerHTML = '<p class="empty-state">Failed to load analysis</p>';
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function renderAnalysis(container, data) {
|
|
1217
|
+
container.innerHTML = '';
|
|
1218
|
+
|
|
1219
|
+
var entityStyles = (window.laminarkGraph && window.laminarkGraph.ENTITY_STYLES) || {};
|
|
1220
|
+
|
|
1221
|
+
// Recent activity
|
|
1222
|
+
if (data.recentActivity) {
|
|
1223
|
+
var actSection = document.createElement('div');
|
|
1224
|
+
actSection.className = 'analysis-section';
|
|
1225
|
+
|
|
1226
|
+
var actTitle = document.createElement('div');
|
|
1227
|
+
actTitle.className = 'analysis-section-title';
|
|
1228
|
+
actTitle.textContent = 'Recent Activity';
|
|
1229
|
+
actSection.appendChild(actTitle);
|
|
1230
|
+
|
|
1231
|
+
var rows = [
|
|
1232
|
+
{ label: 'Last 24 hours', value: data.recentActivity.lastDay },
|
|
1233
|
+
{ label: 'Last 7 days', value: data.recentActivity.lastWeek },
|
|
1234
|
+
];
|
|
1235
|
+
|
|
1236
|
+
rows.forEach(function (r) {
|
|
1237
|
+
var row = document.createElement('div');
|
|
1238
|
+
row.className = 'analysis-stat-row';
|
|
1239
|
+
var lbl = document.createElement('span');
|
|
1240
|
+
lbl.className = 'analysis-stat-label';
|
|
1241
|
+
lbl.textContent = r.label;
|
|
1242
|
+
var val = document.createElement('span');
|
|
1243
|
+
val.className = 'analysis-stat-value';
|
|
1244
|
+
val.textContent = r.value + ' new entities';
|
|
1245
|
+
row.appendChild(lbl);
|
|
1246
|
+
row.appendChild(val);
|
|
1247
|
+
actSection.appendChild(row);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
container.appendChild(actSection);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Entity type distribution
|
|
1254
|
+
if (data.entityTypes && data.entityTypes.length > 0) {
|
|
1255
|
+
var etSection = document.createElement('div');
|
|
1256
|
+
etSection.className = 'analysis-section';
|
|
1257
|
+
|
|
1258
|
+
var etTitle = document.createElement('div');
|
|
1259
|
+
etTitle.className = 'analysis-section-title';
|
|
1260
|
+
etTitle.textContent = 'Entity Types';
|
|
1261
|
+
etSection.appendChild(etTitle);
|
|
1262
|
+
|
|
1263
|
+
var maxCount = data.entityTypes[0].count;
|
|
1264
|
+
|
|
1265
|
+
data.entityTypes.forEach(function (et) {
|
|
1266
|
+
var row = document.createElement('div');
|
|
1267
|
+
row.className = 'analysis-bar-row';
|
|
1268
|
+
|
|
1269
|
+
var label = document.createElement('span');
|
|
1270
|
+
label.className = 'analysis-bar-label';
|
|
1271
|
+
label.textContent = et.type;
|
|
1272
|
+
row.appendChild(label);
|
|
1273
|
+
|
|
1274
|
+
var track = document.createElement('div');
|
|
1275
|
+
track.className = 'analysis-bar-track';
|
|
1276
|
+
var fill = document.createElement('div');
|
|
1277
|
+
fill.className = 'analysis-bar-fill';
|
|
1278
|
+
fill.style.width = Math.round((et.count / maxCount) * 100) + '%';
|
|
1279
|
+
fill.style.background = (entityStyles[et.type] || { color: '#8b949e' }).color;
|
|
1280
|
+
track.appendChild(fill);
|
|
1281
|
+
row.appendChild(track);
|
|
1282
|
+
|
|
1283
|
+
var count = document.createElement('span');
|
|
1284
|
+
count.className = 'analysis-bar-count';
|
|
1285
|
+
count.textContent = et.count;
|
|
1286
|
+
row.appendChild(count);
|
|
1287
|
+
|
|
1288
|
+
etSection.appendChild(row);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
container.appendChild(etSection);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Relationship type distribution
|
|
1295
|
+
if (data.relationshipTypes && data.relationshipTypes.length > 0) {
|
|
1296
|
+
var rtSection = document.createElement('div');
|
|
1297
|
+
rtSection.className = 'analysis-section';
|
|
1298
|
+
|
|
1299
|
+
var rtTitle = document.createElement('div');
|
|
1300
|
+
rtTitle.className = 'analysis-section-title';
|
|
1301
|
+
rtTitle.textContent = 'Relationship Types';
|
|
1302
|
+
rtSection.appendChild(rtTitle);
|
|
1303
|
+
|
|
1304
|
+
var maxRel = data.relationshipTypes[0].count;
|
|
1305
|
+
|
|
1306
|
+
data.relationshipTypes.forEach(function (rt) {
|
|
1307
|
+
var row = document.createElement('div');
|
|
1308
|
+
row.className = 'analysis-bar-row';
|
|
1309
|
+
|
|
1310
|
+
var label = document.createElement('span');
|
|
1311
|
+
label.className = 'analysis-bar-label';
|
|
1312
|
+
label.textContent = rt.type;
|
|
1313
|
+
row.appendChild(label);
|
|
1314
|
+
|
|
1315
|
+
var track = document.createElement('div');
|
|
1316
|
+
track.className = 'analysis-bar-track';
|
|
1317
|
+
var fill = document.createElement('div');
|
|
1318
|
+
fill.className = 'analysis-bar-fill';
|
|
1319
|
+
fill.style.width = Math.round((rt.count / maxRel) * 100) + '%';
|
|
1320
|
+
fill.style.background = '#8b949e';
|
|
1321
|
+
track.appendChild(fill);
|
|
1322
|
+
row.appendChild(track);
|
|
1323
|
+
|
|
1324
|
+
var count = document.createElement('span');
|
|
1325
|
+
count.className = 'analysis-bar-count';
|
|
1326
|
+
count.textContent = rt.count;
|
|
1327
|
+
row.appendChild(count);
|
|
1328
|
+
|
|
1329
|
+
rtSection.appendChild(row);
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
container.appendChild(rtSection);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Top 10 entities by degree
|
|
1336
|
+
if (data.topEntities && data.topEntities.length > 0) {
|
|
1337
|
+
var teSection = document.createElement('div');
|
|
1338
|
+
teSection.className = 'analysis-section';
|
|
1339
|
+
|
|
1340
|
+
var teTitle = document.createElement('div');
|
|
1341
|
+
teTitle.className = 'analysis-section-title';
|
|
1342
|
+
teTitle.textContent = 'Most Connected Entities';
|
|
1343
|
+
teSection.appendChild(teTitle);
|
|
1344
|
+
|
|
1345
|
+
data.topEntities.forEach(function (ent) {
|
|
1346
|
+
var item = document.createElement('div');
|
|
1347
|
+
item.className = 'analysis-entity-item';
|
|
1348
|
+
|
|
1349
|
+
var dot = document.createElement('span');
|
|
1350
|
+
dot.className = 'analysis-entity-dot';
|
|
1351
|
+
dot.style.background = (entityStyles[ent.type] || { color: '#8b949e' }).color;
|
|
1352
|
+
item.appendChild(dot);
|
|
1353
|
+
|
|
1354
|
+
var name = document.createElement('span');
|
|
1355
|
+
name.className = 'analysis-entity-name';
|
|
1356
|
+
name.textContent = ent.label;
|
|
1357
|
+
item.appendChild(name);
|
|
1358
|
+
|
|
1359
|
+
var degree = document.createElement('span');
|
|
1360
|
+
degree.className = 'analysis-entity-degree';
|
|
1361
|
+
degree.textContent = ent.degree + ' links';
|
|
1362
|
+
item.appendChild(degree);
|
|
1363
|
+
|
|
1364
|
+
item.addEventListener('click', function () {
|
|
1365
|
+
if (window.laminarkGraph) {
|
|
1366
|
+
window.laminarkGraph.selectAndCenterNode(ent.id);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
teSection.appendChild(item);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
container.appendChild(teSection);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Connected components / clusters
|
|
1377
|
+
if (data.components && data.components.length > 0) {
|
|
1378
|
+
var ccSection = document.createElement('div');
|
|
1379
|
+
ccSection.className = 'analysis-section';
|
|
1380
|
+
|
|
1381
|
+
var ccTitle = document.createElement('div');
|
|
1382
|
+
ccTitle.className = 'analysis-section-title';
|
|
1383
|
+
ccTitle.textContent = 'Clusters (' + data.components.length + ')';
|
|
1384
|
+
ccSection.appendChild(ccTitle);
|
|
1385
|
+
|
|
1386
|
+
data.components.forEach(function (comp) {
|
|
1387
|
+
var card = document.createElement('div');
|
|
1388
|
+
card.className = 'analysis-cluster-card';
|
|
1389
|
+
|
|
1390
|
+
var label = document.createElement('div');
|
|
1391
|
+
label.className = 'analysis-cluster-label';
|
|
1392
|
+
label.textContent = comp.label;
|
|
1393
|
+
card.appendChild(label);
|
|
1394
|
+
|
|
1395
|
+
var meta = document.createElement('div');
|
|
1396
|
+
meta.className = 'analysis-cluster-meta';
|
|
1397
|
+
meta.textContent = comp.nodeCount + ' nodes, ' + comp.edgeCount + ' edges';
|
|
1398
|
+
card.appendChild(meta);
|
|
1399
|
+
|
|
1400
|
+
card.addEventListener('click', function () {
|
|
1401
|
+
if (window.laminarkGraph) {
|
|
1402
|
+
window.laminarkGraph.highlightCluster(comp.nodeIds);
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
ccSection.appendChild(card);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
container.appendChild(ccSection);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (!data.entityTypes?.length && !data.topEntities?.length) {
|
|
1413
|
+
var empty = document.createElement('p');
|
|
1414
|
+
empty.className = 'empty-state';
|
|
1415
|
+
empty.textContent = 'No graph data to analyze yet.';
|
|
1416
|
+
container.appendChild(empty);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// ---------------------------------------------------------------------------
|
|
1421
|
+
// Initialization
|
|
1422
|
+
// ---------------------------------------------------------------------------
|
|
1423
|
+
|
|
1424
|
+
document.addEventListener('DOMContentLoaded', async function () {
|
|
1425
|
+
console.log('[laminark] Initializing application');
|
|
1426
|
+
|
|
1427
|
+
await initProjectSelector();
|
|
1428
|
+
initNavigation();
|
|
1429
|
+
initFilters();
|
|
1430
|
+
initTimeRange();
|
|
1431
|
+
initDetailPanel();
|
|
1432
|
+
initPathDetailPanel();
|
|
1433
|
+
initSearch();
|
|
1434
|
+
initAnalysis();
|
|
1435
|
+
connectSSE();
|
|
1436
|
+
|
|
1437
|
+
// Initialize activity feed
|
|
1438
|
+
if (window.laminarkActivity) {
|
|
1439
|
+
window.laminarkActivity.initActivityFeed();
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// Initialize settings
|
|
1443
|
+
if (window.laminarkSettings) {
|
|
1444
|
+
window.laminarkSettings.initSettings();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Fetch initial data
|
|
1448
|
+
const [graphData, timelineData] = await Promise.all([
|
|
1449
|
+
fetchGraphData(),
|
|
1450
|
+
fetchTimelineData(),
|
|
1451
|
+
]);
|
|
1452
|
+
|
|
1453
|
+
window.laminarkState.graph = graphData;
|
|
1454
|
+
window.laminarkState.timeline = timelineData;
|
|
1455
|
+
|
|
1456
|
+
console.log('[laminark] Initial data loaded:', {
|
|
1457
|
+
nodes: graphData.nodes.length,
|
|
1458
|
+
edges: graphData.edges.length,
|
|
1459
|
+
sessions: timelineData.sessions.length,
|
|
1460
|
+
observations: timelineData.observations.length,
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
// Initialize the knowledge graph (active by default)
|
|
1464
|
+
if (window.laminarkGraph) {
|
|
1465
|
+
window.laminarkGraph.initGraph('cy');
|
|
1466
|
+
await window.laminarkGraph.loadGraphData();
|
|
1467
|
+
window.laminarkState.graphInitialized = true;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Initialize timeline module (lazy -- waits for tab click, or pre-init if timeline tab is active)
|
|
1471
|
+
var activeTab = document.querySelector('.nav-tab.active');
|
|
1472
|
+
if (activeTab && activeTab.getAttribute('data-view') === 'timeline-view') {
|
|
1473
|
+
if (window.laminarkTimeline) {
|
|
1474
|
+
window.laminarkTimeline.initTimeline('timeline-view');
|
|
1475
|
+
window.laminarkTimeline.loadTimelineData();
|
|
1476
|
+
window.laminarkState.timelineInitialized = true;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Listen for SSE-dispatched events for graph updates (batched for performance)
|
|
1481
|
+
document.addEventListener('laminark:entity_updated', function (e) {
|
|
1482
|
+
if (!window.laminarkGraph) return;
|
|
1483
|
+
var data = e.detail;
|
|
1484
|
+
if (data && data.id) {
|
|
1485
|
+
window.laminarkGraph.queueBatchUpdate({
|
|
1486
|
+
type: 'addNode',
|
|
1487
|
+
data: {
|
|
1488
|
+
id: data.id,
|
|
1489
|
+
label: data.label || data.name,
|
|
1490
|
+
type: data.type,
|
|
1491
|
+
observationCount: data.observationCount || 0,
|
|
1492
|
+
createdAt: data.createdAt,
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
document.addEventListener('laminark:new_observation', function () {
|
|
1499
|
+
// Refresh observation counts by reloading graph data
|
|
1500
|
+
if (window.laminarkGraph) {
|
|
1501
|
+
var filters = getActiveFilters();
|
|
1502
|
+
window.laminarkGraph.loadGraphData(filters ? { type: filters.join(',') } : undefined);
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
// Path overlay event handlers (Plan 02 adds the graph overlay functions)
|
|
1507
|
+
document.addEventListener('laminark:path_started', function (e) {
|
|
1508
|
+
if (window.laminarkGraph && window.laminarkGraph.addPathOverlay) {
|
|
1509
|
+
window.laminarkGraph.addPathOverlay(e.detail);
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
document.addEventListener('laminark:path_waypoint', function (e) {
|
|
1514
|
+
if (window.laminarkGraph && window.laminarkGraph.updatePathOverlay) {
|
|
1515
|
+
window.laminarkGraph.updatePathOverlay(e.detail);
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
document.addEventListener('laminark:path_resolved', function (e) {
|
|
1520
|
+
if (window.laminarkGraph && window.laminarkGraph.resolvePathOverlay) {
|
|
1521
|
+
window.laminarkGraph.resolvePathOverlay(e.detail);
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
// Filter change handler for graph (legacy listener)
|
|
1526
|
+
document.addEventListener('laminark:filter_change', function () {
|
|
1527
|
+
// Filter changes now handled directly in initFilters via graph.filterByType/resetFilters
|
|
1528
|
+
// This listener remains for any external consumers
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
// After initial graph load, update filter pill counts
|
|
1532
|
+
setTimeout(function () {
|
|
1533
|
+
if (window.laminarkGraph && window.laminarkGraph.updateFilterCounts) {
|
|
1534
|
+
window.laminarkGraph.updateFilterCounts();
|
|
1535
|
+
}
|
|
1536
|
+
}, 1000);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
// ---------------------------------------------------------------------------
|
|
1540
|
+
// Timeline rendering
|
|
1541
|
+
// ---------------------------------------------------------------------------
|
|
1542
|
+
|
|
1543
|
+
function renderTimeline(data) {
|
|
1544
|
+
const container = document.getElementById('timeline-content');
|
|
1545
|
+
if (!container) return;
|
|
1546
|
+
|
|
1547
|
+
if (!data.sessions.length && !data.observations.length) {
|
|
1548
|
+
container.innerHTML = '<p class="empty-state">No timeline data yet. Observations will appear here as they are captured.</p>';
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
let html = '';
|
|
1553
|
+
|
|
1554
|
+
// Group observations by session
|
|
1555
|
+
const sessionMap = new Map();
|
|
1556
|
+
data.sessions.forEach(function (s) { sessionMap.set(s.id, s); });
|
|
1557
|
+
|
|
1558
|
+
// Observations without sessions
|
|
1559
|
+
const ungrouped = data.observations.filter(function (o) { return !o.sessionId; });
|
|
1560
|
+
|
|
1561
|
+
// Build session groups
|
|
1562
|
+
if (data.sessions.length > 0) {
|
|
1563
|
+
data.sessions.forEach(function (session) {
|
|
1564
|
+
const sessionObs = data.observations.filter(function (o) { return o.sessionId === session.id; });
|
|
1565
|
+
const shifts = data.topicShifts.filter(function (ts) {
|
|
1566
|
+
return ts.timestamp >= session.startedAt && (!session.endedAt || ts.timestamp <= session.endedAt);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
html += '<div class="timeline-session">';
|
|
1570
|
+
html += '<div class="timeline-session-header">';
|
|
1571
|
+
html += '<span class="session-time">' + formatTime(session.startedAt) + '</span>';
|
|
1572
|
+
html += '<span class="session-summary">' + escapeHtml(session.summary || 'Session ' + session.id.substring(0, 8)) + '</span>';
|
|
1573
|
+
html += '</div>';
|
|
1574
|
+
|
|
1575
|
+
// Interleave observations and topic shifts by time
|
|
1576
|
+
const items = [];
|
|
1577
|
+
sessionObs.forEach(function (o) { items.push({ time: o.createdAt, type: 'obs', data: o }); });
|
|
1578
|
+
shifts.forEach(function (s) { items.push({ time: s.timestamp, type: 'shift', data: s }); });
|
|
1579
|
+
items.sort(function (a, b) { return a.time.localeCompare(b.time); });
|
|
1580
|
+
|
|
1581
|
+
items.forEach(function (item) {
|
|
1582
|
+
if (item.type === 'obs') {
|
|
1583
|
+
const text = item.data.text.length > 300 ? item.data.text.substring(0, 300) + '...' : item.data.text;
|
|
1584
|
+
html += '<div class="timeline-observation">';
|
|
1585
|
+
html += '<span class="obs-time">' + formatTime(item.data.createdAt) + '</span>';
|
|
1586
|
+
html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
|
|
1587
|
+
html += '</div>';
|
|
1588
|
+
} else {
|
|
1589
|
+
html += '<div class="timeline-topic-shift">';
|
|
1590
|
+
html += '\u21BB Topic shift detected';
|
|
1591
|
+
if (item.data.confidence != null) {
|
|
1592
|
+
html += ' (confidence: ' + (item.data.confidence * 100).toFixed(0) + '%)';
|
|
1593
|
+
}
|
|
1594
|
+
html += '</div>';
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
html += '</div>';
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Ungrouped observations
|
|
1603
|
+
if (ungrouped.length > 0) {
|
|
1604
|
+
html += '<div class="timeline-session">';
|
|
1605
|
+
html += '<div class="timeline-session-header">';
|
|
1606
|
+
html += '<span class="session-summary">Ungrouped observations</span>';
|
|
1607
|
+
html += '</div>';
|
|
1608
|
+
ungrouped.forEach(function (obs) {
|
|
1609
|
+
const text = obs.text.length > 300 ? obs.text.substring(0, 300) + '...' : obs.text;
|
|
1610
|
+
html += '<div class="timeline-observation">';
|
|
1611
|
+
html += '<span class="obs-time">' + formatTime(obs.createdAt) + '</span>';
|
|
1612
|
+
html += '<span class="obs-text">' + escapeHtml(text) + '</span>';
|
|
1613
|
+
html += '</div>';
|
|
1614
|
+
});
|
|
1615
|
+
html += '</div>';
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
container.innerHTML = html;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function formatTime(isoString) {
|
|
1622
|
+
if (!isoString) return '';
|
|
1623
|
+
try {
|
|
1624
|
+
const d = new Date(isoString);
|
|
1625
|
+
return d.toLocaleString();
|
|
1626
|
+
} catch {
|
|
1627
|
+
return isoString;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Export helpers for potential use by graph/timeline modules
|
|
1632
|
+
window.laminarkApp = {
|
|
1633
|
+
fetchGraphData: fetchGraphData,
|
|
1634
|
+
fetchTimelineData: fetchTimelineData,
|
|
1635
|
+
fetchNodeDetails: fetchNodeDetails,
|
|
1636
|
+
fetchProjects: fetchProjects,
|
|
1637
|
+
fetchPaths: fetchPaths,
|
|
1638
|
+
fetchPathDetail: fetchPathDetail,
|
|
1639
|
+
showNodeDetails: showNodeDetails,
|
|
1640
|
+
showPathDetails: showPathDetails,
|
|
1641
|
+
getActiveFilters: getActiveFilters,
|
|
1642
|
+
};
|