@grifhinz/logics-manager 2.5.2 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/clients/shared-web/media/css/layout.css +6 -0
- package/clients/viewer/browser-host.js +491 -60
- package/clients/viewer/index.html +39 -19
- package/clients/viewer/viewer.css +425 -8
- package/logics_manager/viewer.py +245 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
|
@@ -8,14 +8,21 @@
|
|
|
8
8
|
const updateBanner = () => document.getElementById("viewer-update");
|
|
9
9
|
const updateCopy = () => document.getElementById("viewer-update-copy");
|
|
10
10
|
const updateCommand = () => document.getElementById("viewer-update-command");
|
|
11
|
+
const connectionBanner = () => document.getElementById("viewer-connection");
|
|
12
|
+
const connectionCopy = () => document.getElementById("viewer-connection-copy");
|
|
13
|
+
const connectionDetail = () => document.getElementById("viewer-connection-detail");
|
|
11
14
|
const filterCount = () => document.getElementById("viewer-filter-count");
|
|
12
15
|
const repoPill = () => document.getElementById("viewer-repo-pill");
|
|
16
|
+
const repoGithubLink = () => document.getElementById("viewer-repo-github");
|
|
17
|
+
const repoFolderButton = () => document.getElementById("viewer-repo-folder");
|
|
18
|
+
const ciButton = () => document.getElementById("viewer-ci");
|
|
13
19
|
const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
|
|
14
20
|
const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
|
|
15
21
|
const refreshMenuButton = () => document.getElementById("viewer-refresh-menu-button");
|
|
16
22
|
const refreshMenuPanel = () => document.getElementById("viewer-refresh-menu");
|
|
17
23
|
const activityClearControl = () => document.getElementById("activity-clear");
|
|
18
24
|
const activityStorageLimit = 80;
|
|
25
|
+
const gitHistoryPageSize = 10;
|
|
19
26
|
const minAutoRefreshIntervalSeconds = 5;
|
|
20
27
|
const maxAutoRefreshIntervalSeconds = 60;
|
|
21
28
|
const defaultAutoRefreshIntervalMs = 15 * 1000;
|
|
@@ -29,6 +36,7 @@
|
|
|
29
36
|
let viewerFilterState = { ...defaultFilterState };
|
|
30
37
|
let latestItems = [];
|
|
31
38
|
let latestRepoRoot = "";
|
|
39
|
+
let latestRepository = { root: "", githubUrl: "" };
|
|
32
40
|
let latestMetaText = "Read-only local viewer";
|
|
33
41
|
let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
|
|
34
42
|
let nextAutoRefreshAt = 0;
|
|
@@ -42,6 +50,9 @@
|
|
|
42
50
|
let mermaidInitialized = false;
|
|
43
51
|
let focusApplied = false;
|
|
44
52
|
let latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
|
|
53
|
+
let latestCiStatus = { visible: false, badgeState: "unknown", message: "" };
|
|
54
|
+
let connectionState = "connected";
|
|
55
|
+
let lastSuccessfulSyncAt = 0;
|
|
45
56
|
|
|
46
57
|
function readStoredState() {
|
|
47
58
|
try {
|
|
@@ -157,6 +168,53 @@
|
|
|
157
168
|
renderMeta();
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
function formatConnectionTime(timestamp) {
|
|
172
|
+
if (!timestamp) {
|
|
173
|
+
return "No successful sync yet";
|
|
174
|
+
}
|
|
175
|
+
return `Last successful sync ${new Date(timestamp).toLocaleTimeString()}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderConnectionNotice() {
|
|
179
|
+
const banner = connectionBanner();
|
|
180
|
+
if (!(banner instanceof HTMLElement)) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (connectionState !== "disconnected") {
|
|
184
|
+
banner.hidden = true;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const copy = connectionCopy();
|
|
188
|
+
const detail = connectionDetail();
|
|
189
|
+
if (copy) {
|
|
190
|
+
copy.textContent = "Local viewer server disconnected. Displayed data may be stale; waiting for reconnection.";
|
|
191
|
+
}
|
|
192
|
+
if (detail) {
|
|
193
|
+
detail.textContent = formatConnectionTime(lastSuccessfulSyncAt);
|
|
194
|
+
}
|
|
195
|
+
banner.hidden = false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function markConnectionHealthy(options = {}) {
|
|
199
|
+
const wasDisconnected = connectionState === "disconnected";
|
|
200
|
+
connectionState = "connected";
|
|
201
|
+
lastSuccessfulSyncAt = Date.now();
|
|
202
|
+
renderConnectionNotice();
|
|
203
|
+
if (wasDisconnected && !options.silent) {
|
|
204
|
+
setMeta(`Reconnected · refreshed ${new Date(lastSuccessfulSyncAt).toLocaleTimeString()}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function markConnectionDisconnected(error) {
|
|
209
|
+
connectionState = "disconnected";
|
|
210
|
+
renderConnectionNotice();
|
|
211
|
+
scheduleNextAutoRefresh();
|
|
212
|
+
const message = error instanceof Error && error.message
|
|
213
|
+
? error.message
|
|
214
|
+
: "Unable to reach local viewer server.";
|
|
215
|
+
setMeta(`Disconnected · ${message}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
160
218
|
function renderMeta() {
|
|
161
219
|
const node = meta();
|
|
162
220
|
if (node) {
|
|
@@ -215,13 +273,52 @@
|
|
|
215
273
|
|
|
216
274
|
function updateRepositoryIdentity(payload) {
|
|
217
275
|
latestRepoRoot = String(payload.root || latestRepoRoot || "");
|
|
276
|
+
const repository = payload.repository && typeof payload.repository === "object" ? payload.repository : {};
|
|
277
|
+
latestRepository = {
|
|
278
|
+
root: String(repository.root || latestRepoRoot || ""),
|
|
279
|
+
githubUrl: String(repository.githubUrl || "")
|
|
280
|
+
};
|
|
218
281
|
const pill = repoPill();
|
|
219
|
-
if (
|
|
282
|
+
if (pill) {
|
|
283
|
+
const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
|
|
284
|
+
pill.textContent = repoName;
|
|
285
|
+
pill.title = latestRepoRoot || repoName;
|
|
286
|
+
}
|
|
287
|
+
updateRepositoryShortcuts();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function updateRepositoryShortcuts() {
|
|
291
|
+
const github = repoGithubLink();
|
|
292
|
+
const folder = repoFolderButton();
|
|
293
|
+
if (github instanceof HTMLAnchorElement) {
|
|
294
|
+
if (latestRepository.githubUrl) {
|
|
295
|
+
github.hidden = false;
|
|
296
|
+
github.href = latestRepository.githubUrl;
|
|
297
|
+
} else {
|
|
298
|
+
github.hidden = true;
|
|
299
|
+
github.removeAttribute("href");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (folder instanceof HTMLButtonElement) {
|
|
303
|
+
folder.hidden = !latestRepository.root;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function openRepositoryFolder() {
|
|
308
|
+
if (!latestRepository.root) {
|
|
309
|
+
setMeta("Repository folder is unavailable.");
|
|
220
310
|
return;
|
|
221
311
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
312
|
+
try {
|
|
313
|
+
const response = await fetch("/api/open-repo-folder", { method: "POST" });
|
|
314
|
+
const data = await response.json();
|
|
315
|
+
if (!response.ok || !data.ok) {
|
|
316
|
+
throw new Error(data.error || "Unable to open repository folder.");
|
|
317
|
+
}
|
|
318
|
+
setMeta("Repository folder opened.");
|
|
319
|
+
} catch (error) {
|
|
320
|
+
setMeta(error instanceof Error ? error.message : "Unable to open repository folder.");
|
|
321
|
+
}
|
|
225
322
|
}
|
|
226
323
|
|
|
227
324
|
function normalizeGitBadgeCounts(payload) {
|
|
@@ -269,6 +366,141 @@
|
|
|
269
366
|
}
|
|
270
367
|
}
|
|
271
368
|
|
|
369
|
+
function ciBadgeTone(value) {
|
|
370
|
+
const state = String(value || "").toLowerCase();
|
|
371
|
+
if (state === "passing") {
|
|
372
|
+
return "passing";
|
|
373
|
+
}
|
|
374
|
+
if (state === "failing") {
|
|
375
|
+
return "failing";
|
|
376
|
+
}
|
|
377
|
+
if (state === "running" || state === "queued") {
|
|
378
|
+
return "running";
|
|
379
|
+
}
|
|
380
|
+
if (state === "cancelled") {
|
|
381
|
+
return "cancelled";
|
|
382
|
+
}
|
|
383
|
+
if (state === "unavailable") {
|
|
384
|
+
return "unavailable";
|
|
385
|
+
}
|
|
386
|
+
return "unknown";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function ciBadgeLabel(value) {
|
|
390
|
+
const state = ciBadgeTone(value);
|
|
391
|
+
if (state === "passing") {
|
|
392
|
+
return "pass";
|
|
393
|
+
}
|
|
394
|
+
if (state === "failing") {
|
|
395
|
+
return "fail";
|
|
396
|
+
}
|
|
397
|
+
if (state === "running") {
|
|
398
|
+
return String(value || "").toLowerCase() === "queued" ? "queue" : "run";
|
|
399
|
+
}
|
|
400
|
+
if (state === "cancelled") {
|
|
401
|
+
return "cancel";
|
|
402
|
+
}
|
|
403
|
+
if (state === "unavailable") {
|
|
404
|
+
return "auth";
|
|
405
|
+
}
|
|
406
|
+
return "n/a";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderCiButtonBadge(payload) {
|
|
410
|
+
const state = payload?.badgeState || payload?.state || "unknown";
|
|
411
|
+
const label = ciBadgeLabel(state);
|
|
412
|
+
const tone = ciBadgeTone(state);
|
|
413
|
+
return `<span class="viewer-ci-badge viewer-ci-badge--${escapeHtml(tone)}" data-viewer-ci-badge title="${escapeHtml(payload?.message || `CI ${label}`)}">${escapeHtml(label)}</span>`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function updateMainCiBadge(payload = latestCiStatus) {
|
|
417
|
+
latestCiStatus = payload && typeof payload === "object" ? payload : { visible: false, badgeState: "unknown", message: "" };
|
|
418
|
+
const button = ciButton();
|
|
419
|
+
if (!(button instanceof HTMLElement)) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
button.querySelector("[data-viewer-ci-badge]")?.remove();
|
|
423
|
+
if (!latestCiStatus.visible) {
|
|
424
|
+
button.hidden = true;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
button.hidden = false;
|
|
428
|
+
button.title = latestCiStatus.message || "Show GitHub Actions CI status";
|
|
429
|
+
button.insertAdjacentHTML("beforeend", renderCiButtonBadge(latestCiStatus));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function refreshCiBadgeCounters() {
|
|
433
|
+
try {
|
|
434
|
+
const response = await fetch("/api/ci-status");
|
|
435
|
+
if (response.status === 404) {
|
|
436
|
+
updateMainCiBadge({ visible: false, badgeState: "unknown", message: "CI status endpoint unavailable." });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const data = await response.json();
|
|
440
|
+
if (response.ok && data.ok) {
|
|
441
|
+
updateMainCiBadge(data.payload);
|
|
442
|
+
}
|
|
443
|
+
} catch {
|
|
444
|
+
updateMainCiBadge({ visible: false, badgeState: "unknown", message: "CI status unavailable." });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function activeCdxAssistantCountFromPayload(payload) {
|
|
449
|
+
if (!payload || payload.state !== "ok") {
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const status = payload.status || {};
|
|
453
|
+
const sessions = cdxSessions(status);
|
|
454
|
+
const sessionActive = sessions.filter((session) => {
|
|
455
|
+
const state = String(session.state || session.status || session.availability || "").toLowerCase();
|
|
456
|
+
return session.active === true ||
|
|
457
|
+
state.includes("active") ||
|
|
458
|
+
state.includes("running") ||
|
|
459
|
+
state.includes("busy");
|
|
460
|
+
}).length;
|
|
461
|
+
if (sessionActive > 0) {
|
|
462
|
+
return sessionActive;
|
|
463
|
+
}
|
|
464
|
+
const rowsActive = cdxRows(status).filter((row) => row.active === true).length;
|
|
465
|
+
if (rowsActive > 0) {
|
|
466
|
+
return rowsActive;
|
|
467
|
+
}
|
|
468
|
+
return cdxProviders(status).reduce((total, provider) => total + Math.max(0, Number(provider.active || 0)), 0);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function updateMainCdxBadge(payload) {
|
|
472
|
+
const button = document.getElementById("viewer-cdx");
|
|
473
|
+
if (!(button instanceof HTMLElement)) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
button.querySelector("[data-viewer-cdx-badge]")?.remove();
|
|
477
|
+
const activeCount = activeCdxAssistantCountFromPayload(payload);
|
|
478
|
+
if (activeCount <= 0) {
|
|
479
|
+
button.title = "Show CDX status";
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const label = activeCount > 9 ? "9+" : String(activeCount);
|
|
483
|
+
const title = activeCount === 1 ? "1 active assistant/session" : `${activeCount} active assistants/sessions`;
|
|
484
|
+
button.title = `Show CDX status · ${title}`;
|
|
485
|
+
button.insertAdjacentHTML("beforeend", `<span class="viewer-cdx-button-badge" data-viewer-cdx-badge title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}">${escapeHtml(label)}</span>`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function refreshCdxBadgeCounters() {
|
|
489
|
+
try {
|
|
490
|
+
const response = await fetch("/api/cdx-status");
|
|
491
|
+
if (response.status === 404) {
|
|
492
|
+
updateMainCdxBadge(null);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const data = await response.json();
|
|
496
|
+
if (response.ok && data.ok) {
|
|
497
|
+
updateMainCdxBadge(data.payload);
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
updateMainCdxBadge(null);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
272
504
|
function setGitBadgeCountsFromPayload(payload, options = {}) {
|
|
273
505
|
latestGitBadgeCounts = normalizeGitBadgeCounts(payload);
|
|
274
506
|
if (options.updateMain !== false) {
|
|
@@ -526,6 +758,7 @@
|
|
|
526
758
|
}
|
|
527
759
|
|
|
528
760
|
function postToApp(payload, options = {}) {
|
|
761
|
+
markConnectionHealthy({ silent: Boolean(options.silent) });
|
|
529
762
|
latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
|
|
530
763
|
if (!autoRefreshIntervalTouched) {
|
|
531
764
|
autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(payload.autoRefreshIntervalSeconds) * 1000;
|
|
@@ -541,6 +774,8 @@
|
|
|
541
774
|
}
|
|
542
775
|
scheduleNextAutoRefresh();
|
|
543
776
|
renderUpdateNotice(payload.updateInfo);
|
|
777
|
+
refreshCiBadgeCounters();
|
|
778
|
+
refreshCdxBadgeCounters();
|
|
544
779
|
updateFilterSummary();
|
|
545
780
|
applyLocalViewerChrome();
|
|
546
781
|
bindRefreshMenuControls();
|
|
@@ -585,6 +820,9 @@
|
|
|
585
820
|
await refreshGitBadgeCounters();
|
|
586
821
|
}
|
|
587
822
|
return true;
|
|
823
|
+
} catch (error) {
|
|
824
|
+
markConnectionDisconnected(error);
|
|
825
|
+
throw error;
|
|
588
826
|
} finally {
|
|
589
827
|
itemsLoadInFlight = false;
|
|
590
828
|
}
|
|
@@ -602,10 +840,18 @@
|
|
|
602
840
|
return Boolean(panel && !panel.hidden && title && title.textContent === "CDX status");
|
|
603
841
|
}
|
|
604
842
|
|
|
843
|
+
function isCiStatusOpen() {
|
|
844
|
+
const panel = documentPanel();
|
|
845
|
+
const title = documentTitle();
|
|
846
|
+
return Boolean(panel && !panel.hidden && title && title.textContent === "CI status");
|
|
847
|
+
}
|
|
848
|
+
|
|
605
849
|
async function refreshViewer(method = "POST", options = {}) {
|
|
606
850
|
await loadItems(method, options);
|
|
607
851
|
if (isGitStatusOpen()) {
|
|
608
852
|
await showGitStatus({ preserve: true, silent: Boolean(options.silent) });
|
|
853
|
+
} else if (isCiStatusOpen()) {
|
|
854
|
+
await showCiStatus({ silent: Boolean(options.silent) });
|
|
609
855
|
} else if (isCdxStatusOpen()) {
|
|
610
856
|
await showCdxStatus({ silent: Boolean(options.silent) });
|
|
611
857
|
} else if (method === "POST") {
|
|
@@ -893,14 +1139,43 @@
|
|
|
893
1139
|
}
|
|
894
1140
|
|
|
895
1141
|
function renderMetricCards(entries) {
|
|
896
|
-
return entries.map(([label, value]) => `
|
|
897
|
-
<div class="viewer-insights__card">
|
|
1142
|
+
return entries.map(([label, value, tone]) => `
|
|
1143
|
+
<div class="viewer-insights__card${tone ? ` viewer-insights__card--${escapeHtml(tone)}` : ""}">
|
|
898
1144
|
<div class="viewer-insights__label">${escapeHtml(label)}</div>
|
|
899
1145
|
<div class="viewer-insights__value">${escapeHtml(value)}</div>
|
|
900
1146
|
</div>
|
|
901
1147
|
`).join("");
|
|
902
1148
|
}
|
|
903
1149
|
|
|
1150
|
+
function renderInsightBars(entries, total) {
|
|
1151
|
+
const denominator = Math.max(1, Number(total) || 0);
|
|
1152
|
+
if (!entries.length) {
|
|
1153
|
+
return '<li class="viewer-insights__bar-row">No corpus shape available</li>';
|
|
1154
|
+
}
|
|
1155
|
+
return entries.map(([label, value]) => {
|
|
1156
|
+
const count = Number(value) || 0;
|
|
1157
|
+
const width = Math.max(count > 0 ? 4 : 0, Math.min(100, Math.round((count / denominator) * 100)));
|
|
1158
|
+
return `
|
|
1159
|
+
<li class="viewer-insights__bar-row">
|
|
1160
|
+
<div class="viewer-insights__bar-meta"><span>${escapeHtml(label)}</span><strong>${escapeHtml(count)}</strong></div>
|
|
1161
|
+
<div class="viewer-insights__bar-track" aria-hidden="true"><span style="width: ${width}%"></span></div>
|
|
1162
|
+
</li>
|
|
1163
|
+
`;
|
|
1164
|
+
}).join("");
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function renderSignalRows(items, emptyText = "No signals") {
|
|
1168
|
+
if (!items.length) {
|
|
1169
|
+
return `<li class="viewer-insights__signal viewer-insights__signal--empty">${escapeHtml(emptyText)}</li>`;
|
|
1170
|
+
}
|
|
1171
|
+
return items.map(([label, value, tone]) => `
|
|
1172
|
+
<li class="viewer-insights__signal${tone ? ` viewer-insights__signal--${escapeHtml(tone)}` : ""}">
|
|
1173
|
+
<span>${escapeHtml(label)}</span>
|
|
1174
|
+
<strong>${escapeHtml(value)}</strong>
|
|
1175
|
+
</li>
|
|
1176
|
+
`).join("");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
904
1179
|
function renderInsightRows(items, emptyText = "No signals") {
|
|
905
1180
|
if (!items.length) {
|
|
906
1181
|
return `<li class="viewer-insights__item">${escapeHtml(emptyText)}</li>`;
|
|
@@ -1046,63 +1321,80 @@
|
|
|
1046
1321
|
const stageRows = Object.entries(countsByStage)
|
|
1047
1322
|
.sort((left, right) => String(left[0]).localeCompare(String(right[0])))
|
|
1048
1323
|
.map(([stage, count]) => [stage, count]);
|
|
1324
|
+
const qualityTotal = qualityFindings.length;
|
|
1325
|
+
const needsAttention = blocked.length + incompleteChains.length + brokenRefs.length + missingStatus.length + qualityTotal;
|
|
1326
|
+
const activeQuiet = Math.max(0, open.length - recentlyModified.length - staleActive.length);
|
|
1327
|
+
const primaryState = needsAttention > 0
|
|
1328
|
+
? `${needsAttention} signals need attention`
|
|
1329
|
+
: "No immediate workflow risk detected";
|
|
1049
1330
|
return `
|
|
1050
1331
|
<div class="viewer-insights">
|
|
1051
|
-
<
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
</section>
|
|
1063
|
-
<section class="viewer-insights__section">
|
|
1064
|
-
<h2>Flow health</h2>
|
|
1065
|
-
<ul class="viewer-insights__list">${renderInsightRows([
|
|
1066
|
-
["Incomplete workflow chains", incompleteChains.length],
|
|
1067
|
-
["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length],
|
|
1068
|
-
["Orphan or unlinked docs", unlinked.length],
|
|
1069
|
-
["Broken reference risks", brokenRefs.length]
|
|
1070
|
-
])}</ul>
|
|
1071
|
-
<ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
|
|
1072
|
-
</section>
|
|
1073
|
-
<section class="viewer-insights__section">
|
|
1074
|
-
<h2>Activity</h2>
|
|
1075
|
-
<ul class="viewer-insights__list">${renderInsightRows([
|
|
1076
|
-
["Latest changes", recentRows.map(itemLabel).join(", ") || "None"],
|
|
1077
|
-
["Stale active docs", staleActive.map(itemLabel).join(", ") || "None"],
|
|
1078
|
-
["Recently active docs", recentlyModified.slice(0, 8).map(itemLabel).join(", ") || "None"],
|
|
1079
|
-
["Activity classification", `recent ${recentlyModified.length}, stale ${open.filter(isStale).length}, quiet ${Math.max(0, open.length - recentlyModified.length)}`]
|
|
1080
|
-
])}</ul>
|
|
1081
|
-
<ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
|
|
1082
|
-
</section>
|
|
1083
|
-
<section class="viewer-insights__section">
|
|
1084
|
-
<h2>Traceability</h2>
|
|
1085
|
-
<ul class="viewer-insights__list">${renderInsightRows([
|
|
1086
|
-
["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
|
|
1087
|
-
["Unlinked docs", unlinked.slice(0, 8).map((item) => item.id).join(", ") || "None"],
|
|
1088
|
-
["Broken references", brokenRefs.slice(0, 8).join(", ") || "None"],
|
|
1089
|
-
["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
|
|
1090
|
-
])}</ul>
|
|
1091
|
-
<ul class="viewer-insights__rows">${renderDocRows(unlinked, "No unlinked documents")}${renderPathRows(brokenRefs, "No broken references")}</ul>
|
|
1092
|
-
</section>
|
|
1093
|
-
<section class="viewer-insights__section">
|
|
1094
|
-
<h2>Quality signals</h2>
|
|
1095
|
-
<ul class="viewer-insights__list">${renderInsightRows([
|
|
1096
|
-
["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
|
|
1097
|
-
["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
|
|
1098
|
-
["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
|
|
1099
|
-
])}</ul>
|
|
1100
|
-
<ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
|
|
1332
|
+
<section class="viewer-insights__hero">
|
|
1333
|
+
<div>
|
|
1334
|
+
<h2>Overview</h2>
|
|
1335
|
+
<p>${escapeHtml(primaryState)} across ${escapeHtml(docs.length)} workflow docs.</p>
|
|
1336
|
+
</div>
|
|
1337
|
+
<div class="viewer-insights__summary">${renderMetricCards([
|
|
1338
|
+
["Docs", docs.length],
|
|
1339
|
+
["Needs attention", needsAttention, needsAttention ? "warning" : "ok"],
|
|
1340
|
+
["Recent 7d", recentlyModified.length],
|
|
1341
|
+
["Quality findings", qualityTotal, qualityTotal ? "warning" : "ok"]
|
|
1342
|
+
])}</div>
|
|
1101
1343
|
</section>
|
|
1102
1344
|
<section class="viewer-insights__section">
|
|
1103
1345
|
<h2>Operator actions</h2>
|
|
1104
|
-
<ul class="viewer-insights__rows">${renderActionRows(actions)}</ul>
|
|
1346
|
+
<ul class="viewer-insights__rows viewer-insights__rows--actions">${renderActionRows(actions)}</ul>
|
|
1105
1347
|
</section>
|
|
1348
|
+
<div class="viewer-insights__workspace">
|
|
1349
|
+
<section class="viewer-insights__section">
|
|
1350
|
+
<h2>Corpus shape</h2>
|
|
1351
|
+
<ul class="viewer-insights__bars">${renderInsightBars(stageRows, docs.length)}</ul>
|
|
1352
|
+
<ul class="viewer-insights__list">${renderInsightRows([
|
|
1353
|
+
["Open", open.length],
|
|
1354
|
+
["Closed", closed.length],
|
|
1355
|
+
["Blocked", blocked.length],
|
|
1356
|
+
["Missing status", missingStatus.length]
|
|
1357
|
+
])}</ul>
|
|
1358
|
+
</section>
|
|
1359
|
+
<section class="viewer-insights__section">
|
|
1360
|
+
<h2>Flow health</h2>
|
|
1361
|
+
<ul class="viewer-insights__signals">${renderSignalRows([
|
|
1362
|
+
["Incomplete workflow chains", incompleteChains.length, incompleteChains.length ? "warning" : "ok"],
|
|
1363
|
+
["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length, incompleteChains.length ? "warning" : "ok"],
|
|
1364
|
+
["Orphan or unlinked docs", unlinked.length, unlinked.length ? "muted" : "ok"],
|
|
1365
|
+
["Broken reference risks", brokenRefs.length, brokenRefs.length ? "warning" : "ok"]
|
|
1366
|
+
])}</ul>
|
|
1367
|
+
<ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
|
|
1368
|
+
</section>
|
|
1369
|
+
<section class="viewer-insights__section">
|
|
1370
|
+
<h2>Activity</h2>
|
|
1371
|
+
<ul class="viewer-insights__signals">${renderSignalRows([
|
|
1372
|
+
["Recently active docs", recentlyModified.length],
|
|
1373
|
+
["Stale active docs", staleActive.length, staleActive.length ? "warning" : "ok"],
|
|
1374
|
+
["Quiet active docs", activeQuiet]
|
|
1375
|
+
])}</ul>
|
|
1376
|
+
<ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
|
|
1377
|
+
</section>
|
|
1378
|
+
<section class="viewer-insights__section">
|
|
1379
|
+
<h2>Traceability</h2>
|
|
1380
|
+
<ul class="viewer-insights__signals">${renderSignalRows([
|
|
1381
|
+
["Broken references", brokenRefs.length, brokenRefs.length ? "warning" : "ok"],
|
|
1382
|
+
["Unlinked docs", unlinked.length, unlinked.length ? "muted" : "ok"],
|
|
1383
|
+
["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
|
|
1384
|
+
["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
|
|
1385
|
+
])}</ul>
|
|
1386
|
+
<ul class="viewer-insights__rows">${renderPathRows(brokenRefs, "No broken references")}${renderDocRows(unlinked, "No unlinked documents")}</ul>
|
|
1387
|
+
</section>
|
|
1388
|
+
<section class="viewer-insights__section viewer-insights__section--wide">
|
|
1389
|
+
<h2>Quality signals</h2>
|
|
1390
|
+
<ul class="viewer-insights__signals">${renderSignalRows([
|
|
1391
|
+
["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded", qualityTotal ? "warning" : "ok"],
|
|
1392
|
+
["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
|
|
1393
|
+
["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
|
|
1394
|
+
])}</ul>
|
|
1395
|
+
<ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
|
|
1396
|
+
</section>
|
|
1397
|
+
</div>
|
|
1106
1398
|
</div>
|
|
1107
1399
|
`;
|
|
1108
1400
|
}
|
|
@@ -1702,10 +1994,108 @@
|
|
|
1702
1994
|
if (!response.ok || !data.ok) {
|
|
1703
1995
|
throw new Error(data.error || "Unable to load CDX status.");
|
|
1704
1996
|
}
|
|
1997
|
+
updateMainCdxBadge(data.payload);
|
|
1705
1998
|
setDocument("CDX status", renderCdxStatus(data.payload));
|
|
1706
1999
|
setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
|
|
1707
2000
|
}
|
|
1708
2001
|
|
|
2002
|
+
function renderCiBadge(value) {
|
|
2003
|
+
const tone = ciBadgeTone(value);
|
|
2004
|
+
return `<span class="viewer-ci__badge viewer-ci__badge--${escapeHtml(tone)}">${escapeHtml(ciBadgeLabel(value))}</span>`;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function formatCiDate(value) {
|
|
2008
|
+
const timestamp = Date.parse(String(value || ""));
|
|
2009
|
+
if (!Number.isFinite(timestamp)) {
|
|
2010
|
+
return "";
|
|
2011
|
+
}
|
|
2012
|
+
return new Date(timestamp).toLocaleString();
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
function renderCiStatus(payload) {
|
|
2016
|
+
if (!payload || !payload.visible) {
|
|
2017
|
+
return `
|
|
2018
|
+
<div class="viewer-ci">
|
|
2019
|
+
<div class="viewer-ci__state">${escapeHtml(payload?.message || "GitHub Actions CI is not configured for this repository.")}</div>
|
|
2020
|
+
</div>
|
|
2021
|
+
`;
|
|
2022
|
+
}
|
|
2023
|
+
const run = payload.run && typeof payload.run === "object" ? payload.run : null;
|
|
2024
|
+
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
|
|
2025
|
+
const state = payload.badgeState || run?.badgeState || payload.state || "unknown";
|
|
2026
|
+
const cards = renderMetricCards([
|
|
2027
|
+
["State", ciBadgeLabel(state)],
|
|
2028
|
+
["Branch", run?.branch || payload.branch || "Unknown"],
|
|
2029
|
+
["Commit", (run?.headSha || payload.headSha || "").slice(0, 7) || "Unknown"],
|
|
2030
|
+
["Match", run?.matchSource === "head" ? "Current HEAD" : "Latest branch run"]
|
|
2031
|
+
]);
|
|
2032
|
+
const runUrl = run?.htmlUrl ? `<a class="viewer-ci__link" href="${escapeHtml(run.htmlUrl)}" target="_blank" rel="noreferrer">Open in GitHub</a>` : "";
|
|
2033
|
+
const runRows = run ? [
|
|
2034
|
+
["Workflow", run.workflowName || run.name || "GitHub Actions"],
|
|
2035
|
+
["Status", `${run.status || "unknown"}${run.conclusion ? ` / ${run.conclusion}` : ""}`],
|
|
2036
|
+
["Event", run.event || "Unknown"],
|
|
2037
|
+
["Commit", run.commitMessage || payload.subject || "Unknown"],
|
|
2038
|
+
["Author", run.author || payload.author || "Unknown"],
|
|
2039
|
+
["Started", formatCiDate(run.runStartedAt || run.createdAt) || "Unknown"],
|
|
2040
|
+
["Updated", formatCiDate(run.updatedAt) || "Unknown"]
|
|
2041
|
+
].map(([label, value]) => `
|
|
2042
|
+
<li class="viewer-ci__row"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></li>
|
|
2043
|
+
`).join("") : `<li class="viewer-ci__empty">${escapeHtml(payload.message || "No GitHub Actions run found for this branch.")}</li>`;
|
|
2044
|
+
const jobRows = jobs.length ? jobs.map((job) => {
|
|
2045
|
+
const jobState = ciBadgeTone(job.conclusion || job.status);
|
|
2046
|
+
const content = `
|
|
2047
|
+
<span>${escapeHtml(job.name || "Job")}</span>
|
|
2048
|
+
<strong>${escapeHtml([job.status, job.conclusion].filter(Boolean).join(" / ") || "unknown")}</strong>
|
|
2049
|
+
`;
|
|
2050
|
+
return `<li class="viewer-ci__job viewer-ci__job--${escapeHtml(jobState)}">${job.htmlUrl ? `<a href="${escapeHtml(job.htmlUrl)}" target="_blank" rel="noreferrer">${content}</a>` : content}</li>`;
|
|
2051
|
+
}).join("") : `<li class="viewer-ci__empty">No job details reported.</li>`;
|
|
2052
|
+
return `
|
|
2053
|
+
<div class="viewer-ci">
|
|
2054
|
+
<div class="viewer-ci__summary">${cards}</div>
|
|
2055
|
+
<div class="viewer-ci__workspace">
|
|
2056
|
+
<section class="viewer-ci__section">
|
|
2057
|
+
<div class="viewer-ci__heading"><h2>Latest run</h2>${renderCiBadge(state)}</div>
|
|
2058
|
+
<ul class="viewer-ci__list">${runRows}</ul>
|
|
2059
|
+
${runUrl}
|
|
2060
|
+
</section>
|
|
2061
|
+
<section class="viewer-ci__section">
|
|
2062
|
+
<div class="viewer-ci__heading"><h2>Jobs</h2><span>${escapeHtml(jobs.length)} reported</span></div>
|
|
2063
|
+
<ul class="viewer-ci__jobs">${jobRows}</ul>
|
|
2064
|
+
</section>
|
|
2065
|
+
</div>
|
|
2066
|
+
</div>
|
|
2067
|
+
`;
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async function showCiStatus(options = {}) {
|
|
2071
|
+
if (!options.silent) {
|
|
2072
|
+
setMeta("Checking CI status...");
|
|
2073
|
+
}
|
|
2074
|
+
const response = await fetch("/api/ci-status");
|
|
2075
|
+
let data = {};
|
|
2076
|
+
try {
|
|
2077
|
+
data = await response.json();
|
|
2078
|
+
} catch {
|
|
2079
|
+
data = {};
|
|
2080
|
+
}
|
|
2081
|
+
if (response.status === 404) {
|
|
2082
|
+
setDocument("CI status", renderCiStatus({
|
|
2083
|
+
visible: true,
|
|
2084
|
+
state: "unavailable",
|
|
2085
|
+
badgeState: "unavailable",
|
|
2086
|
+
message: "CI status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
|
|
2087
|
+
}));
|
|
2088
|
+
setMeta("Restart the local viewer to enable CI status.");
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
if (!response.ok || !data.ok) {
|
|
2092
|
+
throw new Error(data.error || "Unable to load CI status.");
|
|
2093
|
+
}
|
|
2094
|
+
updateMainCiBadge(data.payload);
|
|
2095
|
+
setDocument("CI status", renderCiStatus(data.payload));
|
|
2096
|
+
setMeta(options.silent ? "CI status refreshed." : "CI status loaded.");
|
|
2097
|
+
}
|
|
2098
|
+
|
|
1709
2099
|
function renderGitStatus(payload) {
|
|
1710
2100
|
if (!payload || payload.state !== "ok") {
|
|
1711
2101
|
return `
|
|
@@ -1785,9 +2175,16 @@
|
|
|
1785
2175
|
const untrackedSections = renderFileSections(["untracked"]);
|
|
1786
2176
|
const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
|
|
1787
2177
|
const recentCommits = Array.isArray(payload.recentCommits) ? payload.recentCommits : [];
|
|
2178
|
+
const renderGitHistoryReveal = (hiddenCount) => {
|
|
2179
|
+
if (hiddenCount <= 0) {
|
|
2180
|
+
return "";
|
|
2181
|
+
}
|
|
2182
|
+
const nextCount = Math.min(gitHistoryPageSize, hiddenCount);
|
|
2183
|
+
return `<li class="viewer-git__commit-row viewer-git__commit-row--reveal"><button class="viewer-git__reveal" type="button" data-viewer-git-history-reveal>Show ${escapeHtml(nextCount)} more</button></li>`;
|
|
2184
|
+
};
|
|
1788
2185
|
const historyRows = recentCommits.length
|
|
1789
|
-
? recentCommits.map((commit) => `
|
|
1790
|
-
<li class="viewer-git__commit-row">
|
|
2186
|
+
? recentCommits.map((commit, index) => `
|
|
2187
|
+
<li class="viewer-git__commit-row" ${index >= gitHistoryPageSize ? "hidden data-viewer-git-history-hidden" : ""}>
|
|
1791
2188
|
<div class="viewer-git__commit-main">
|
|
1792
2189
|
<code>${escapeHtml(commit.hash || "")}</code>
|
|
1793
2190
|
<strong>${escapeHtml(commit.subject || "Untitled commit")}</strong>
|
|
@@ -1797,7 +2194,7 @@
|
|
|
1797
2194
|
${commit.refs ? `<span class="viewer-git__commit-refs">${escapeHtml(commit.refs)}</span>` : ""}
|
|
1798
2195
|
</div>
|
|
1799
2196
|
</li>
|
|
1800
|
-
`).join("")
|
|
2197
|
+
`).join("") + renderGitHistoryReveal(Math.max(0, recentCommits.length - gitHistoryPageSize))
|
|
1801
2198
|
: `<li class="viewer-git__commit-row">${escapeHtml(payload.latestCommit || "No commit history available.")}</li>`;
|
|
1802
2199
|
const history = `
|
|
1803
2200
|
<section class="viewer-git__section">
|
|
@@ -2021,6 +2418,7 @@
|
|
|
2021
2418
|
applyLocalViewerChrome();
|
|
2022
2419
|
[document.getElementById("viewer-insights")].forEach((button) => {
|
|
2023
2420
|
button?.addEventListener("click", () => {
|
|
2421
|
+
setRefreshMenuOpen(false);
|
|
2024
2422
|
showCorpusInsights().catch((error) => setMeta(error.message));
|
|
2025
2423
|
});
|
|
2026
2424
|
});
|
|
@@ -2070,14 +2468,21 @@
|
|
|
2070
2468
|
});
|
|
2071
2469
|
});
|
|
2072
2470
|
document.getElementById("viewer-health")?.addEventListener("click", () => {
|
|
2471
|
+
setRefreshMenuOpen(false);
|
|
2073
2472
|
showHealth().catch((error) => setMeta(error.message));
|
|
2074
2473
|
});
|
|
2075
2474
|
document.getElementById("viewer-git")?.addEventListener("click", () => {
|
|
2076
2475
|
showGitStatus().catch((error) => setMeta(error.message));
|
|
2077
2476
|
});
|
|
2477
|
+
ciButton()?.addEventListener("click", () => {
|
|
2478
|
+
showCiStatus().catch((error) => setMeta(error.message));
|
|
2479
|
+
});
|
|
2078
2480
|
document.getElementById("viewer-cdx")?.addEventListener("click", () => {
|
|
2079
2481
|
showCdxStatus().catch((error) => setMeta(error.message));
|
|
2080
2482
|
});
|
|
2483
|
+
repoFolderButton()?.addEventListener("click", () => {
|
|
2484
|
+
openRepositoryFolder().catch((error) => setMeta(error.message));
|
|
2485
|
+
});
|
|
2081
2486
|
activityClearControl()?.addEventListener("click", () => {
|
|
2082
2487
|
clearActivityHistory();
|
|
2083
2488
|
});
|
|
@@ -2110,8 +2515,34 @@
|
|
|
2110
2515
|
const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
|
|
2111
2516
|
const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
|
|
2112
2517
|
const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
|
|
2518
|
+
const gitHistoryRevealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-history-reveal]") : null;
|
|
2113
2519
|
const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
|
|
2114
2520
|
const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
|
|
2521
|
+
if (gitHistoryRevealTarget instanceof HTMLElement) {
|
|
2522
|
+
event.preventDefault();
|
|
2523
|
+
event.stopImmediatePropagation();
|
|
2524
|
+
if (gitHistoryRevealTarget.dataset.viewerGitHistoryBusy === "true") {
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
gitHistoryRevealTarget.dataset.viewerGitHistoryBusy = "true";
|
|
2528
|
+
const list = gitHistoryRevealTarget.closest("ul");
|
|
2529
|
+
const hiddenRows = Array.from(list?.querySelectorAll("[data-viewer-git-history-hidden]") || [])
|
|
2530
|
+
.filter((row) => row instanceof HTMLElement);
|
|
2531
|
+
hiddenRows.slice(0, gitHistoryPageSize).forEach((row) => {
|
|
2532
|
+
if (row instanceof HTMLElement) {
|
|
2533
|
+
row.hidden = false;
|
|
2534
|
+
row.removeAttribute("data-viewer-git-history-hidden");
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
const remaining = Array.from(list?.querySelectorAll("[data-viewer-git-history-hidden]") || []).length;
|
|
2538
|
+
if (remaining > 0) {
|
|
2539
|
+
gitHistoryRevealTarget.textContent = `Show ${Math.min(gitHistoryPageSize, remaining)} more`;
|
|
2540
|
+
gitHistoryRevealTarget.dataset.viewerGitHistoryBusy = "false";
|
|
2541
|
+
} else {
|
|
2542
|
+
gitHistoryRevealTarget.closest("li")?.remove();
|
|
2543
|
+
}
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2115
2546
|
if (revealTarget instanceof HTMLElement) {
|
|
2116
2547
|
const list = revealTarget.closest("ul");
|
|
2117
2548
|
list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {
|