@grifhinz/logics-manager 2.3.3 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/css/toolbar.css +41 -3
- package/clients/shared-web/media/renderMarkdown.js +1 -1
- package/clients/shared-web/media/webviewChrome.js +31 -8
- package/clients/shared-web/media/webviewSelectors.js +8 -7
- package/clients/viewer/browser-host.js +1329 -48
- package/clients/viewer/index.html +30 -28
- package/clients/viewer/viewer.css +686 -0
- package/logics_manager/audit.py +2 -2
- package/logics_manager/insights.py +1 -1
- package/logics_manager/lint.py +2 -2
- package/logics_manager/viewer.py +377 -11
- package/package.json +4 -2
- package/pyproject.toml +1 -1
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
const stateKey = "logics.localViewer.state";
|
|
3
|
-
const autoRefreshIntervalMs = 60 * 1000;
|
|
4
3
|
const meta = () => document.getElementById("viewer-meta");
|
|
5
4
|
const documentPanel = () => document.getElementById("viewer-document");
|
|
6
5
|
const documentTitle = () => document.getElementById("viewer-document-title");
|
|
@@ -10,6 +9,16 @@
|
|
|
10
9
|
const updateCopy = () => document.getElementById("viewer-update-copy");
|
|
11
10
|
const updateCommand = () => document.getElementById("viewer-update-command");
|
|
12
11
|
const filterCount = () => document.getElementById("viewer-filter-count");
|
|
12
|
+
const repoPill = () => document.getElementById("viewer-repo-pill");
|
|
13
|
+
const autoRefreshControl = () => document.getElementById("viewer-auto-refresh");
|
|
14
|
+
const refreshIntervalControl = () => document.getElementById("viewer-refresh-interval");
|
|
15
|
+
const refreshMenuButton = () => document.getElementById("viewer-refresh-menu-button");
|
|
16
|
+
const refreshMenuPanel = () => document.getElementById("viewer-refresh-menu");
|
|
17
|
+
const activityClearControl = () => document.getElementById("activity-clear");
|
|
18
|
+
const activityStorageLimit = 80;
|
|
19
|
+
const minAutoRefreshIntervalSeconds = 5;
|
|
20
|
+
const maxAutoRefreshIntervalSeconds = 60;
|
|
21
|
+
const defaultAutoRefreshIntervalMs = 15 * 1000;
|
|
13
22
|
const defaultFilterState = {
|
|
14
23
|
focus: "active",
|
|
15
24
|
type: "all",
|
|
@@ -19,12 +28,20 @@
|
|
|
19
28
|
};
|
|
20
29
|
let viewerFilterState = { ...defaultFilterState };
|
|
21
30
|
let latestItems = [];
|
|
31
|
+
let latestRepoRoot = "";
|
|
32
|
+
let latestMetaText = "Read-only local viewer";
|
|
33
|
+
let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
|
|
34
|
+
let nextAutoRefreshAt = 0;
|
|
35
|
+
let autoRefreshEnabled = true;
|
|
36
|
+
let autoRefreshTimeoutId = 0;
|
|
37
|
+
let autoRefreshIntervalTouched = false;
|
|
22
38
|
let applyingLocalChrome = false;
|
|
23
39
|
let autoRefreshStarted = false;
|
|
24
40
|
let itemsLoadInFlight = false;
|
|
25
41
|
let refreshAfterVisible = false;
|
|
26
42
|
let mermaidInitialized = false;
|
|
27
43
|
let focusApplied = false;
|
|
44
|
+
let latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
|
|
28
45
|
|
|
29
46
|
function readStoredState() {
|
|
30
47
|
try {
|
|
@@ -62,6 +79,52 @@
|
|
|
62
79
|
writeStoredState({ ...nextState, viewerFilterState: { ...viewerFilterState } });
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
function updateStoredActivity(nextItems) {
|
|
83
|
+
const storedState = readStoredState();
|
|
84
|
+
const baseState = storedState && typeof storedState === "object" ? storedState : {};
|
|
85
|
+
const previousSnapshot = baseState.activitySnapshot && typeof baseState.activitySnapshot === "object"
|
|
86
|
+
? baseState.activitySnapshot
|
|
87
|
+
: {};
|
|
88
|
+
const history = Array.isArray(baseState.activityHistory) ? [...baseState.activityHistory] : [];
|
|
89
|
+
const nextSnapshot = {};
|
|
90
|
+
const now = new Date().toISOString();
|
|
91
|
+
const decorated = nextItems.map((item) => {
|
|
92
|
+
const relPath = String(item.relPath || item.path || item.id || "");
|
|
93
|
+
const status = String(item?.indicators?.Status || "").trim();
|
|
94
|
+
if (relPath) {
|
|
95
|
+
nextSnapshot[relPath] = { status, updatedAt: item.updatedAt || "" };
|
|
96
|
+
}
|
|
97
|
+
const previous = relPath ? previousSnapshot[relPath] : null;
|
|
98
|
+
const previousStatus = String(previous?.status || "").trim();
|
|
99
|
+
const statusChanged = Boolean(previousStatus && status && previousStatus !== status);
|
|
100
|
+
if (relPath && (statusChanged || !previous)) {
|
|
101
|
+
history.unshift({ path: relPath, at: now, status, previousStatus, type: statusChanged ? "status-change" : "updated" });
|
|
102
|
+
}
|
|
103
|
+
return statusChanged ? { ...item, activityType: "status-change" } : item;
|
|
104
|
+
});
|
|
105
|
+
writeStoredState({
|
|
106
|
+
...baseState,
|
|
107
|
+
viewerFilterState: { ...viewerFilterState },
|
|
108
|
+
activitySnapshot: nextSnapshot,
|
|
109
|
+
activityHistory: history.slice(0, activityStorageLimit)
|
|
110
|
+
});
|
|
111
|
+
return decorated;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function clearActivityHistory() {
|
|
115
|
+
const storedState = readStoredState();
|
|
116
|
+
const nextState = storedState && typeof storedState === "object" ? { ...storedState } : {};
|
|
117
|
+
delete nextState.activitySnapshot;
|
|
118
|
+
delete nextState.activityHistory;
|
|
119
|
+
writeStoredState(nextState);
|
|
120
|
+
latestItems = latestItems.map((item) => {
|
|
121
|
+
const clone = { ...item };
|
|
122
|
+
delete clone.activityType;
|
|
123
|
+
return clone;
|
|
124
|
+
});
|
|
125
|
+
setMeta("Local activity history cleared.");
|
|
126
|
+
}
|
|
127
|
+
|
|
65
128
|
function markdownApi() {
|
|
66
129
|
if (typeof window.createCdxLogicsMarkdownApi === "function") {
|
|
67
130
|
return window.createCdxLogicsMarkdownApi();
|
|
@@ -90,9 +153,139 @@
|
|
|
90
153
|
}
|
|
91
154
|
|
|
92
155
|
function setMeta(text) {
|
|
156
|
+
latestMetaText = text;
|
|
157
|
+
renderMeta();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderMeta() {
|
|
93
161
|
const node = meta();
|
|
94
162
|
if (node) {
|
|
95
|
-
|
|
163
|
+
const parts = [latestMetaText];
|
|
164
|
+
if (autoRefreshEnabled && nextAutoRefreshAt > 0) {
|
|
165
|
+
const seconds = Math.max(0, Math.ceil((nextAutoRefreshAt - Date.now()) / 1000));
|
|
166
|
+
parts.push(`next auto refresh in ${seconds}s`);
|
|
167
|
+
}
|
|
168
|
+
node.textContent = parts.join(" · ");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeAutoRefreshIntervalSeconds(value) {
|
|
173
|
+
const seconds = Math.round(Number(value));
|
|
174
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
175
|
+
return defaultAutoRefreshIntervalMs / 1000;
|
|
176
|
+
}
|
|
177
|
+
return Math.min(maxAutoRefreshIntervalSeconds, Math.max(minAutoRefreshIntervalSeconds, seconds));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function updateRefreshIntervalControl() {
|
|
181
|
+
const control = refreshIntervalControl();
|
|
182
|
+
if (!(control instanceof HTMLSelectElement)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const seconds = String(Math.round(autoRefreshIntervalMs / 1000));
|
|
186
|
+
if (![...control.options].some((option) => option.value === seconds)) {
|
|
187
|
+
const option = document.createElement("option");
|
|
188
|
+
option.value = seconds;
|
|
189
|
+
option.textContent = `${seconds} sec`;
|
|
190
|
+
control.appendChild(option);
|
|
191
|
+
}
|
|
192
|
+
control.value = seconds;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function setAutoRefreshIntervalSeconds(value, options = {}) {
|
|
196
|
+
autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(value) * 1000;
|
|
197
|
+
if (options.user) {
|
|
198
|
+
autoRefreshIntervalTouched = true;
|
|
199
|
+
}
|
|
200
|
+
updateRefreshIntervalControl();
|
|
201
|
+
scheduleNextAutoRefresh();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scheduleNextAutoRefresh() {
|
|
205
|
+
if (autoRefreshTimeoutId) {
|
|
206
|
+
window.clearTimeout(autoRefreshTimeoutId);
|
|
207
|
+
autoRefreshTimeoutId = 0;
|
|
208
|
+
}
|
|
209
|
+
nextAutoRefreshAt = autoRefreshEnabled ? Date.now() + autoRefreshIntervalMs : 0;
|
|
210
|
+
if (autoRefreshEnabled) {
|
|
211
|
+
autoRefreshTimeoutId = window.setTimeout(autoRefreshItems, autoRefreshIntervalMs);
|
|
212
|
+
}
|
|
213
|
+
renderMeta();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function updateRepositoryIdentity(payload) {
|
|
217
|
+
latestRepoRoot = String(payload.root || latestRepoRoot || "");
|
|
218
|
+
const pill = repoPill();
|
|
219
|
+
if (!pill) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
|
|
223
|
+
pill.textContent = repoName;
|
|
224
|
+
pill.title = latestRepoRoot || repoName;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeGitBadgeCounts(payload) {
|
|
228
|
+
const counts = payload && typeof payload === "object" ? payload.badgeCounts || {} : {};
|
|
229
|
+
return {
|
|
230
|
+
unpushedCommits: Math.max(0, Number(counts.unpushedCommits || payload?.ahead || 0)),
|
|
231
|
+
uncommittedFiles: Math.max(0, Number(counts.uncommittedFiles || 0))
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function renderGitBadge(kind, count) {
|
|
236
|
+
const value = Number(count || 0);
|
|
237
|
+
if (value <= 0) {
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
const label = kind === "commits"
|
|
241
|
+
? `${value} commits locaux non pushés`
|
|
242
|
+
: `${value} fichiers modifiés non commités`;
|
|
243
|
+
return `<span class="viewer-git-badge viewer-git-badge--${kind}" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${escapeHtml(value)}</span>`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function gitBadgeHtml(scope) {
|
|
247
|
+
const commitsVisible = latestGitBadgeCounts.unpushedCommits > 0 && (
|
|
248
|
+
scope === "main" || scope === "history"
|
|
249
|
+
);
|
|
250
|
+
const filesVisible = latestGitBadgeCounts.uncommittedFiles > 0 && (
|
|
251
|
+
scope === "main" || scope === "changes"
|
|
252
|
+
);
|
|
253
|
+
const html = [
|
|
254
|
+
commitsVisible ? renderGitBadge("commits", latestGitBadgeCounts.unpushedCommits) : "",
|
|
255
|
+
filesVisible ? renderGitBadge("files", latestGitBadgeCounts.uncommittedFiles) : ""
|
|
256
|
+
].filter(Boolean).join("");
|
|
257
|
+
return html ? `<span class="viewer-git-badges" data-viewer-git-badges="${escapeHtml(scope)}">${html}</span>` : "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function updateMainGitBadges() {
|
|
261
|
+
const button = document.getElementById("viewer-git");
|
|
262
|
+
if (!(button instanceof HTMLElement)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
button.querySelector('[data-viewer-git-badges="main"]')?.remove();
|
|
266
|
+
const html = gitBadgeHtml("main");
|
|
267
|
+
if (html) {
|
|
268
|
+
button.insertAdjacentHTML("beforeend", html);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function setGitBadgeCountsFromPayload(payload, options = {}) {
|
|
273
|
+
latestGitBadgeCounts = normalizeGitBadgeCounts(payload);
|
|
274
|
+
if (options.updateMain !== false) {
|
|
275
|
+
updateMainGitBadges();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function refreshGitBadgeCounters() {
|
|
280
|
+
try {
|
|
281
|
+
const response = await fetch("/api/git-status");
|
|
282
|
+
const data = await response.json();
|
|
283
|
+
if (response.ok && data.ok && data.payload?.state === "ok") {
|
|
284
|
+
setGitBadgeCountsFromPayload(data.payload);
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
latestGitBadgeCounts = { unpushedCommits: 0, uncommittedFiles: 0 };
|
|
288
|
+
updateMainGitBadges();
|
|
96
289
|
}
|
|
97
290
|
}
|
|
98
291
|
|
|
@@ -333,16 +526,24 @@
|
|
|
333
526
|
}
|
|
334
527
|
|
|
335
528
|
function postToApp(payload, options = {}) {
|
|
336
|
-
latestItems = Array.isArray(payload.items) ? payload.items : [];
|
|
337
|
-
|
|
529
|
+
latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
|
|
530
|
+
if (!autoRefreshIntervalTouched) {
|
|
531
|
+
autoRefreshIntervalMs = normalizeAutoRefreshIntervalSeconds(payload.autoRefreshIntervalSeconds) * 1000;
|
|
532
|
+
updateRefreshIntervalControl();
|
|
533
|
+
}
|
|
534
|
+
updateRepositoryIdentity(payload);
|
|
535
|
+
const payloadWithActivity = { ...payload, items: latestItems };
|
|
536
|
+
const nextPayload = options.silent ? payloadWithActivity : applyFocusRequest(payloadWithActivity);
|
|
338
537
|
window.dispatchEvent(new MessageEvent("message", { data: { type: "data", payload: nextPayload } }));
|
|
339
538
|
const rootName = payload.root ? payload.root.split(/[\\/]/).filter(Boolean).pop() : "repository";
|
|
340
539
|
if (!options.silent) {
|
|
341
540
|
setMeta(`${rootName} · ${payload.items.length} docs · refreshed ${new Date().toLocaleTimeString()}`);
|
|
342
541
|
}
|
|
542
|
+
scheduleNextAutoRefresh();
|
|
343
543
|
renderUpdateNotice(payload.updateInfo);
|
|
344
544
|
updateFilterSummary();
|
|
345
545
|
applyLocalViewerChrome();
|
|
546
|
+
bindRefreshMenuControls();
|
|
346
547
|
}
|
|
347
548
|
|
|
348
549
|
function renderUpdateNotice(updateInfo) {
|
|
@@ -380,18 +581,47 @@
|
|
|
380
581
|
throw new Error(data.error || "Unable to load viewer data.");
|
|
381
582
|
}
|
|
382
583
|
postToApp(data.payload, { silent: Boolean(options.silent) });
|
|
584
|
+
if (method !== "POST") {
|
|
585
|
+
await refreshGitBadgeCounters();
|
|
586
|
+
}
|
|
383
587
|
return true;
|
|
384
588
|
} finally {
|
|
385
589
|
itemsLoadInFlight = false;
|
|
386
590
|
}
|
|
387
591
|
}
|
|
388
592
|
|
|
593
|
+
function isGitStatusOpen() {
|
|
594
|
+
const panel = documentPanel();
|
|
595
|
+
const title = documentTitle();
|
|
596
|
+
return Boolean(panel && !panel.hidden && title && title.textContent === "Git status");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function isCdxStatusOpen() {
|
|
600
|
+
const panel = documentPanel();
|
|
601
|
+
const title = documentTitle();
|
|
602
|
+
return Boolean(panel && !panel.hidden && title && title.textContent === "CDX status");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function refreshViewer(method = "POST", options = {}) {
|
|
606
|
+
await loadItems(method, options);
|
|
607
|
+
if (isGitStatusOpen()) {
|
|
608
|
+
await showGitStatus({ preserve: true, silent: Boolean(options.silent) });
|
|
609
|
+
} else if (isCdxStatusOpen()) {
|
|
610
|
+
await showCdxStatus({ silent: Boolean(options.silent) });
|
|
611
|
+
} else if (method === "POST") {
|
|
612
|
+
await refreshGitBadgeCounters();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
389
616
|
function autoRefreshItems() {
|
|
617
|
+
if (!autoRefreshEnabled) {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
390
620
|
if (document.hidden) {
|
|
391
621
|
refreshAfterVisible = true;
|
|
392
622
|
return;
|
|
393
623
|
}
|
|
394
|
-
|
|
624
|
+
refreshViewer("POST", { silent: true }).catch((error) => setMeta(error.message));
|
|
395
625
|
}
|
|
396
626
|
|
|
397
627
|
function startAutoRefresh() {
|
|
@@ -399,7 +629,9 @@
|
|
|
399
629
|
return;
|
|
400
630
|
}
|
|
401
631
|
autoRefreshStarted = true;
|
|
402
|
-
window.setInterval(
|
|
632
|
+
window.setInterval(() => {
|
|
633
|
+
renderMeta();
|
|
634
|
+
}, 1000);
|
|
403
635
|
document.addEventListener("visibilitychange", () => {
|
|
404
636
|
if (!document.hidden && refreshAfterVisible) {
|
|
405
637
|
refreshAfterVisible = false;
|
|
@@ -408,13 +640,57 @@
|
|
|
408
640
|
});
|
|
409
641
|
}
|
|
410
642
|
|
|
643
|
+
function setAutoRefreshEnabled(enabled) {
|
|
644
|
+
autoRefreshEnabled = Boolean(enabled);
|
|
645
|
+
const control = autoRefreshControl();
|
|
646
|
+
if (control instanceof HTMLInputElement) {
|
|
647
|
+
control.checked = autoRefreshEnabled;
|
|
648
|
+
}
|
|
649
|
+
scheduleNextAutoRefresh();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function setRefreshMenuOpen(open) {
|
|
653
|
+
const panel = refreshMenuPanel();
|
|
654
|
+
const button = refreshMenuButton();
|
|
655
|
+
if (!panel) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
panel.hidden = !open;
|
|
659
|
+
if (button instanceof HTMLElement) {
|
|
660
|
+
button.setAttribute("aria-expanded", open ? "true" : "false");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function bindRefreshMenuControls() {
|
|
665
|
+
const button = refreshMenuButton();
|
|
666
|
+
if (button) {
|
|
667
|
+
button.onclick = (event) => {
|
|
668
|
+
event.stopPropagation();
|
|
669
|
+
const panel = refreshMenuPanel();
|
|
670
|
+
setRefreshMenuOpen(Boolean(panel?.hidden));
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const panel = refreshMenuPanel();
|
|
674
|
+
if (panel) {
|
|
675
|
+
panel.onclick = (event) => {
|
|
676
|
+
event.stopPropagation();
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
411
681
|
function statusValue(item) {
|
|
412
682
|
return String(item?.indicators?.Status || "").toLowerCase();
|
|
413
683
|
}
|
|
414
684
|
|
|
415
685
|
function isClosed(item) {
|
|
416
686
|
const status = statusValue(item);
|
|
417
|
-
return
|
|
687
|
+
return (
|
|
688
|
+
status.includes("done") ||
|
|
689
|
+
status.includes("archived") ||
|
|
690
|
+
status.includes("obsolete") ||
|
|
691
|
+
status.includes("superseded") ||
|
|
692
|
+
status.includes("settled")
|
|
693
|
+
);
|
|
418
694
|
}
|
|
419
695
|
|
|
420
696
|
function hasLinks(item) {
|
|
@@ -435,6 +711,52 @@
|
|
|
435
711
|
return timestamp > 0 && timestamp < Date.now() - 30 * 24 * 60 * 60 * 1000 && !isClosed(item);
|
|
436
712
|
}
|
|
437
713
|
|
|
714
|
+
function isRecent(item, days = 7) {
|
|
715
|
+
return updatedWithin(item, days);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function hasMissingOrAmbiguousStatus(item) {
|
|
719
|
+
const rawStatus = String(item?.indicators?.Status || "").trim();
|
|
720
|
+
if (!rawStatus) {
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
const normalized = rawStatus.toLowerCase();
|
|
724
|
+
return ![
|
|
725
|
+
"draft",
|
|
726
|
+
"ready",
|
|
727
|
+
"in progress",
|
|
728
|
+
"blocked",
|
|
729
|
+
"done",
|
|
730
|
+
"active",
|
|
731
|
+
"proposed",
|
|
732
|
+
"accepted",
|
|
733
|
+
"validated",
|
|
734
|
+
"rejected",
|
|
735
|
+
"superseded",
|
|
736
|
+
"settled",
|
|
737
|
+
"archived",
|
|
738
|
+
"obsolete"
|
|
739
|
+
].includes(normalized);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function isSafeLogicsDocPath(value) {
|
|
743
|
+
const path = String(value || "").replace(/\\/g, "/").replace(/^\.?\//, "").trim();
|
|
744
|
+
if (!path || path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
if (path.split("/").includes("..") || !path.endsWith(".md")) {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
return [
|
|
751
|
+
"logics/request/",
|
|
752
|
+
"logics/backlog/",
|
|
753
|
+
"logics/tasks/",
|
|
754
|
+
"logics/product/",
|
|
755
|
+
"logics/architecture/",
|
|
756
|
+
"logics/specs/"
|
|
757
|
+
].some((prefix) => path.startsWith(prefix));
|
|
758
|
+
}
|
|
759
|
+
|
|
438
760
|
function matchesViewerFilter(item) {
|
|
439
761
|
if (!item) {
|
|
440
762
|
return false;
|
|
@@ -562,58 +884,233 @@
|
|
|
562
884
|
count.textContent = `${visibleCount} of ${latestItems.length} docs shown${suffix}`;
|
|
563
885
|
}
|
|
564
886
|
|
|
565
|
-
function
|
|
566
|
-
|
|
567
|
-
|
|
887
|
+
function countBy(items, selector) {
|
|
888
|
+
return items.reduce((acc, item) => {
|
|
889
|
+
const key = selector(item) || "unknown";
|
|
890
|
+
acc[key] = (acc[key] || 0) + 1;
|
|
568
891
|
return acc;
|
|
569
892
|
}, {});
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const cards = [
|
|
575
|
-
["Docs", latestItems.length],
|
|
576
|
-
["Active", active],
|
|
577
|
-
["Blocked", blocked],
|
|
578
|
-
["Unlinked", unlinked]
|
|
579
|
-
].map(([label, value]) => `
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function renderMetricCards(entries) {
|
|
896
|
+
return entries.map(([label, value]) => `
|
|
580
897
|
<div class="viewer-insights__card">
|
|
581
898
|
<div class="viewer-insights__label">${escapeHtml(label)}</div>
|
|
582
899
|
<div class="viewer-insights__value">${escapeHtml(value)}</div>
|
|
583
900
|
</div>
|
|
584
901
|
`).join("");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function renderInsightRows(items, emptyText = "No signals") {
|
|
905
|
+
if (!items.length) {
|
|
906
|
+
return `<li class="viewer-insights__item">${escapeHtml(emptyText)}</li>`;
|
|
907
|
+
}
|
|
908
|
+
return items.map(([label, value]) => `
|
|
909
|
+
<li class="viewer-insights__item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></li>
|
|
910
|
+
`).join("");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function renderDocRows(items, emptyText = "None", limit = 6) {
|
|
914
|
+
if (!items.length) {
|
|
915
|
+
return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
|
|
916
|
+
}
|
|
917
|
+
const rows = items.map((item, index) => {
|
|
918
|
+
const path = item.relPath || item.path || "";
|
|
919
|
+
const control = path && isSafeLogicsDocPath(path)
|
|
920
|
+
? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(item.id || path)}</button>`
|
|
921
|
+
: `<span class="viewer-insights__doc">${escapeHtml(item.id || path || item.title)}</span>`;
|
|
922
|
+
return `
|
|
923
|
+
<li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>
|
|
924
|
+
${control}
|
|
925
|
+
<span>${escapeHtml(item.indicators?.Status || item.stage || "No status")}</span>
|
|
926
|
+
</li>
|
|
927
|
+
`;
|
|
928
|
+
});
|
|
929
|
+
const hiddenCount = Math.max(0, items.length - limit);
|
|
930
|
+
if (hiddenCount > 0) {
|
|
931
|
+
rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
|
|
932
|
+
}
|
|
933
|
+
return rows.join("");
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function renderPathRows(paths, emptyText = "None", limit = 6) {
|
|
937
|
+
if (!paths.length) {
|
|
938
|
+
return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
|
|
939
|
+
}
|
|
940
|
+
const rows = paths.map((path, index) => {
|
|
941
|
+
const control = isSafeLogicsDocPath(path)
|
|
942
|
+
? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
|
|
943
|
+
: `<span class="viewer-insights__doc">${escapeHtml(path)}</span>`;
|
|
944
|
+
return `<li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>${control}</li>`;
|
|
945
|
+
});
|
|
946
|
+
const hiddenCount = Math.max(0, paths.length - limit);
|
|
947
|
+
if (hiddenCount > 0) {
|
|
948
|
+
rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
|
|
949
|
+
}
|
|
950
|
+
return rows.join("");
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function renderActionRows(actions) {
|
|
954
|
+
return actions.map((action) => {
|
|
955
|
+
if (action.filter) {
|
|
956
|
+
return `
|
|
957
|
+
<li class="viewer-insights__row">
|
|
958
|
+
<button class="viewer-insights__action" type="button" data-viewer-filter-group="${escapeHtml(action.filter.group)}" data-viewer-filter-value="${escapeHtml(action.filter.value)}">${escapeHtml(action.label)}</button>
|
|
959
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
960
|
+
</li>
|
|
961
|
+
`;
|
|
962
|
+
}
|
|
963
|
+
if (action.health) {
|
|
964
|
+
return `
|
|
965
|
+
<li class="viewer-insights__row">
|
|
966
|
+
<button class="viewer-insights__action" type="button" data-viewer-open-health>${escapeHtml(action.label)}</button>
|
|
967
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
968
|
+
</li>
|
|
969
|
+
`;
|
|
970
|
+
}
|
|
971
|
+
if (action.path && isSafeLogicsDocPath(action.path)) {
|
|
972
|
+
return `
|
|
973
|
+
<li class="viewer-insights__row">
|
|
974
|
+
<button class="viewer-insights__action" type="button" data-viewer-doc-path="${escapeHtml(action.path)}">${escapeHtml(action.label)}</button>
|
|
975
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
976
|
+
</li>
|
|
977
|
+
`;
|
|
978
|
+
}
|
|
979
|
+
return `<li class="viewer-insights__row"><span>${escapeHtml(action.label)}</span><strong>${escapeHtml(action.value)}</strong></li>`;
|
|
980
|
+
}).join("");
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function itemLabel(item) {
|
|
984
|
+
return `${item.id || item.relPath || "doc"} - ${item.indicators?.Status || "No status"}`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function buildCorpusInsights(lintData = null, auditData = null) {
|
|
988
|
+
const docs = latestItems;
|
|
989
|
+
const itemPaths = new Set(docs.map((item) => item.relPath).filter(Boolean));
|
|
990
|
+
const countsByStage = countBy(docs, (item) => item.stage);
|
|
991
|
+
const closed = docs.filter(isClosed);
|
|
992
|
+
const open = docs.filter((item) => !isClosed(item));
|
|
993
|
+
const blocked = docs.filter((item) => statusValue(item).includes("blocked"));
|
|
994
|
+
const missingStatus = docs.filter(hasMissingOrAmbiguousStatus);
|
|
995
|
+
const recentlyModified = docs.filter((item) => isRecent(item, 7));
|
|
996
|
+
const incompleteChains = docs.filter((item) => ["request", "backlog"].includes(item.stage) && !item.isPromoted && !isClosed(item));
|
|
997
|
+
const unlinked = docs.filter((item) => (item.references || []).length === 0 && (item.usedBy || []).length === 0);
|
|
998
|
+
const brokenRefs = [];
|
|
999
|
+
const relationshipCounts = {};
|
|
1000
|
+
docs.forEach((item) => {
|
|
1001
|
+
relationshipCounts[item.stage] = (relationshipCounts[item.stage] || 0) + (item.references || []).length + (item.usedBy || []).length;
|
|
1002
|
+
(item.references || []).forEach((ref) => {
|
|
1003
|
+
if (ref.path && !itemPaths.has(ref.path)) {
|
|
1004
|
+
brokenRefs.push(`${item.id} -> ${ref.path}`);
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
const mostReferenced = [...docs]
|
|
1009
|
+
.sort((left, right) => (right.usedBy || []).length - (left.usedBy || []).length)
|
|
1010
|
+
.filter((item) => (item.usedBy || []).length > 0)
|
|
1011
|
+
.slice(0, 8);
|
|
1012
|
+
const recentRows = [...docs]
|
|
1013
|
+
.sort((left, right) => (Date.parse(right.updatedAt || "") || 0) - (Date.parse(left.updatedAt || "") || 0))
|
|
1014
|
+
.slice(0, 8);
|
|
1015
|
+
const staleActive = open.filter(isStale).slice(0, 8);
|
|
1016
|
+
const qualityFindings = lintData && auditData ? collectHealthFindings(lintData, auditData) : [];
|
|
1017
|
+
const qualityBySource = countBy(qualityFindings, (finding) => finding.source || finding.code || "finding");
|
|
1018
|
+
const qualityByDocType = countBy(qualityFindings, (finding) => {
|
|
1019
|
+
const path = String(finding.path || "");
|
|
1020
|
+
const matched = docs.find((item) => item.relPath === path);
|
|
1021
|
+
return matched?.stage || (path ? "unknown document" : "repository");
|
|
1022
|
+
});
|
|
1023
|
+
const concentratedIssues = Object.entries(countBy(qualityFindings, (finding) => finding.path || "repository"))
|
|
1024
|
+
.sort((left, right) => Number(right[1]) - Number(left[1]))
|
|
1025
|
+
.slice(0, 8);
|
|
1026
|
+
const actions = [];
|
|
1027
|
+
if (blocked.length) {
|
|
1028
|
+
actions.push({ label: "Review blocked workflow docs", value: blocked.length, filter: { group: "focus", value: "blocked" } });
|
|
1029
|
+
}
|
|
1030
|
+
if (incompleteChains.length) {
|
|
1031
|
+
actions.push({ label: "Promote or close incomplete workflow chains", value: incompleteChains.length, filter: { group: "focus", value: "needs-promotion" } });
|
|
1032
|
+
}
|
|
1033
|
+
if (brokenRefs.length) {
|
|
1034
|
+
actions.push({ label: "Repair broken references", value: brokenRefs.length, health: true });
|
|
1035
|
+
}
|
|
1036
|
+
if (qualityFindings.length) {
|
|
1037
|
+
actions.push({ label: "Open validation health", value: qualityFindings.length, health: true });
|
|
1038
|
+
}
|
|
1039
|
+
if (missingStatus.length) {
|
|
1040
|
+
actions.push({ label: "Normalize missing or ambiguous statuses", value: missingStatus.length, path: missingStatus[0]?.relPath || "" });
|
|
1041
|
+
}
|
|
1042
|
+
if (!actions.length) {
|
|
1043
|
+
actions.push({ label: "No immediate operator action detected", value: "OK" });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
585
1046
|
const stageRows = Object.entries(countsByStage)
|
|
586
1047
|
.sort((left, right) => String(left[0]).localeCompare(String(right[0])))
|
|
587
|
-
.map(([stage, count]) =>
|
|
588
|
-
.join("");
|
|
589
|
-
const recentRows = [...latestItems]
|
|
590
|
-
.sort((left, right) => (Date.parse(right.updatedAt || "") || 0) - (Date.parse(left.updatedAt || "") || 0))
|
|
591
|
-
.slice(0, 8)
|
|
592
|
-
.map((item) => `<li class="viewer-insights__item"><span>${escapeHtml(item.id)}</span><strong>${escapeHtml(item.indicators?.Status || "No status")}</strong></li>`)
|
|
593
|
-
.join("");
|
|
1048
|
+
.map(([stage, count]) => [stage, count]);
|
|
594
1049
|
return `
|
|
595
1050
|
<div class="viewer-insights">
|
|
596
|
-
<div class="viewer-insights__summary">${
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
1051
|
+
<div class="viewer-insights__summary">${renderMetricCards([
|
|
1052
|
+
["Docs", docs.length],
|
|
1053
|
+
["Open", open.length],
|
|
1054
|
+
["Closed", closed.length],
|
|
1055
|
+
["Blocked", blocked.length],
|
|
1056
|
+
["Missing status", missingStatus.length],
|
|
1057
|
+
["Modified 7d", recentlyModified.length]
|
|
1058
|
+
])}</div>
|
|
1059
|
+
<section class="viewer-insights__section">
|
|
1060
|
+
<h2>Overview</h2>
|
|
1061
|
+
<ul class="viewer-insights__list">${renderInsightRows(stageRows, "No docs loaded")}</ul>
|
|
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>
|
|
603
1073
|
<section class="viewer-insights__section">
|
|
604
|
-
<h2>
|
|
605
|
-
<ul class="viewer-insights__list">${
|
|
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>
|
|
606
1082
|
</section>
|
|
607
1083
|
<section class="viewer-insights__section">
|
|
608
|
-
<h2>
|
|
609
|
-
<ul class="viewer-insights__list">${
|
|
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>
|
|
1101
|
+
</section>
|
|
1102
|
+
<section class="viewer-insights__section">
|
|
1103
|
+
<h2>Operator actions</h2>
|
|
1104
|
+
<ul class="viewer-insights__rows">${renderActionRows(actions)}</ul>
|
|
610
1105
|
</section>
|
|
611
1106
|
</div>
|
|
612
1107
|
`;
|
|
613
1108
|
}
|
|
614
1109
|
|
|
615
|
-
function showCorpusInsights() {
|
|
616
|
-
|
|
1110
|
+
async function showCorpusInsights() {
|
|
1111
|
+
const [lintResponse, auditResponse] = await Promise.all([fetch("/api/lint"), fetch("/api/audit")]);
|
|
1112
|
+
const [lintData, auditData] = await Promise.all([lintResponse.json(), auditResponse.json()]);
|
|
1113
|
+
setDocument("Corpus insights", buildCorpusInsights(lintData, auditData));
|
|
617
1114
|
setMeta("Corpus insights loaded.");
|
|
618
1115
|
}
|
|
619
1116
|
|
|
@@ -711,9 +1208,9 @@
|
|
|
711
1208
|
const list = findings.length
|
|
712
1209
|
? findings.slice(0, 50).map((finding) => {
|
|
713
1210
|
const path = finding.path || "";
|
|
714
|
-
const pathControl = path
|
|
1211
|
+
const pathControl = path && isSafeLogicsDocPath(path)
|
|
715
1212
|
? `<button class="viewer-health__path" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
|
|
716
|
-
:
|
|
1213
|
+
: `<span class="viewer-health__meta">${escapeHtml(path ? `Repository-level or unsafe path: ${path}` : "Repository-level finding")}</span>`;
|
|
717
1214
|
const severity = finding.severity || finding.code || finding.source || "finding";
|
|
718
1215
|
return `
|
|
719
1216
|
<li class="viewer-health__issue">
|
|
@@ -744,6 +1241,710 @@
|
|
|
744
1241
|
setMeta("Health loaded.");
|
|
745
1242
|
}
|
|
746
1243
|
|
|
1244
|
+
function objectEntries(value) {
|
|
1245
|
+
return value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function asArray(value) {
|
|
1249
|
+
if (Array.isArray(value)) {
|
|
1250
|
+
return value;
|
|
1251
|
+
}
|
|
1252
|
+
if (value && typeof value === "object") {
|
|
1253
|
+
return Object.entries(value).map(([key, entry]) => ({ name: key, ...(entry && typeof entry === "object" ? entry : { value: entry }) }));
|
|
1254
|
+
}
|
|
1255
|
+
return [];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function pickFirstObject(status, keys) {
|
|
1259
|
+
for (const key of keys) {
|
|
1260
|
+
if (status?.[key] && typeof status[key] === "object" && !Array.isArray(status[key])) {
|
|
1261
|
+
return status[key];
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return {};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function pickFirstArray(status, keys) {
|
|
1268
|
+
for (const key of keys) {
|
|
1269
|
+
const entries = asArray(status?.[key]);
|
|
1270
|
+
if (entries.length) {
|
|
1271
|
+
return entries;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return [];
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function cdxRows(status) {
|
|
1278
|
+
return asArray(status?.rows);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function cdxProviders(status) {
|
|
1282
|
+
const explicitProviders = pickFirstArray(status, ["providers", "providerStatus", "provider_status"]);
|
|
1283
|
+
if (explicitProviders.length) {
|
|
1284
|
+
return explicitProviders;
|
|
1285
|
+
}
|
|
1286
|
+
const grouped = new Map();
|
|
1287
|
+
cdxRows(status).forEach((row) => {
|
|
1288
|
+
const provider = String(row.provider || "unknown");
|
|
1289
|
+
const current = grouped.get(provider) || { name: provider, enabled: 0, active: 0, authenticated: 0, sessions: 0, lowest_available_pct: null };
|
|
1290
|
+
current.sessions += 1;
|
|
1291
|
+
if (row.enabled) {
|
|
1292
|
+
current.enabled += 1;
|
|
1293
|
+
}
|
|
1294
|
+
if (row.active) {
|
|
1295
|
+
current.active += 1;
|
|
1296
|
+
}
|
|
1297
|
+
if (String(row.auth_status || "").toLowerCase() === "authenticated") {
|
|
1298
|
+
current.authenticated += 1;
|
|
1299
|
+
}
|
|
1300
|
+
if (typeof row.available_pct === "number") {
|
|
1301
|
+
current.lowest_available_pct = current.lowest_available_pct === null
|
|
1302
|
+
? row.available_pct
|
|
1303
|
+
: Math.min(current.lowest_available_pct, row.available_pct);
|
|
1304
|
+
}
|
|
1305
|
+
current.state = current.active > 0 ? "active" : current.enabled > 0 ? "enabled" : "disabled";
|
|
1306
|
+
grouped.set(provider, current);
|
|
1307
|
+
});
|
|
1308
|
+
return Array.from(grouped.values());
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function cdxSessions(status) {
|
|
1312
|
+
const explicitSessions = pickFirstArray(status, ["sessions", "activeSessions", "active_sessions"]);
|
|
1313
|
+
return sortCdxSessionsByRemaining(explicitSessions.length ? explicitSessions : cdxRows(status));
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function cdxReadiness(status) {
|
|
1317
|
+
const explicitReadiness = pickFirstObject(status, ["readiness", "quota", "quotas", "limits"]);
|
|
1318
|
+
if (objectEntries(explicitReadiness).length) {
|
|
1319
|
+
return explicitReadiness;
|
|
1320
|
+
}
|
|
1321
|
+
const rows = cdxRows(status);
|
|
1322
|
+
if (!rows.length) {
|
|
1323
|
+
return {};
|
|
1324
|
+
}
|
|
1325
|
+
const enabled = rows.filter((row) => row.enabled).length;
|
|
1326
|
+
const active = rows.filter((row) => row.active).length;
|
|
1327
|
+
const authenticated = rows.filter((row) => String(row.auth_status || "").toLowerCase() === "authenticated").length;
|
|
1328
|
+
const availableValues = rows.map((row) => row.available_pct).filter((value) => typeof value === "number");
|
|
1329
|
+
const lowestAvailable = availableValues.length ? Math.min(...availableValues) : null;
|
|
1330
|
+
return {
|
|
1331
|
+
enabled_sessions: enabled,
|
|
1332
|
+
active_sessions: active,
|
|
1333
|
+
authenticated_sessions: authenticated,
|
|
1334
|
+
lowest_remaining: lowestAvailable === null ? "not reported" : `${lowestAvailable}%`
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function renderCdxObjectRows(value, emptyText) {
|
|
1339
|
+
const rows = objectEntries(value).slice(0, 12).map(([key, entry]) => `
|
|
1340
|
+
<li class="viewer-cdx__row">
|
|
1341
|
+
<span>${escapeHtml(cdxLabel(key))}</span>
|
|
1342
|
+
<strong>${escapeHtml(typeof entry === "object" ? JSON.stringify(entry) : entry)}</strong>
|
|
1343
|
+
</li>
|
|
1344
|
+
`).join("");
|
|
1345
|
+
return rows || `<li class="viewer-cdx__empty">${escapeHtml(emptyText)}</li>`;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function cdxLabel(value) {
|
|
1349
|
+
return String(value || "")
|
|
1350
|
+
.replace(/[_-]+/g, " ")
|
|
1351
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function cdxStateClass(value) {
|
|
1355
|
+
const state = String(value || "").toLowerCase();
|
|
1356
|
+
if (["ready", "ok", "active", "enabled", "authenticated"].some((entry) => state.includes(entry))) {
|
|
1357
|
+
return "ok";
|
|
1358
|
+
}
|
|
1359
|
+
if (["starting", "pending", "warning", "low", "limited"].some((entry) => state.includes(entry))) {
|
|
1360
|
+
return "warn";
|
|
1361
|
+
}
|
|
1362
|
+
if (["error", "failed", "disabled", "unavailable", "unauthenticated"].some((entry) => state.includes(entry))) {
|
|
1363
|
+
return "bad";
|
|
1364
|
+
}
|
|
1365
|
+
return "neutral";
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function cdxRemainingPct(item) {
|
|
1369
|
+
const value = item?.remaining_pct ?? item?.remainingPct ?? item?.available_pct ?? item?.availablePct ?? item?.lowest_available_pct ?? item?.lowestAvailablePct;
|
|
1370
|
+
const percent = Number(value);
|
|
1371
|
+
return Number.isFinite(percent) ? Math.max(0, Math.min(100, Math.round(percent))) : null;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function cdxPct(value) {
|
|
1375
|
+
const percent = Number(value);
|
|
1376
|
+
return Number.isFinite(percent) ? `${Math.max(0, Math.min(100, Math.round(percent)))}%` : "-";
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function cdxField(item, keys, fallback = "-") {
|
|
1380
|
+
for (const key of keys) {
|
|
1381
|
+
const value = item?.[key];
|
|
1382
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
1383
|
+
return value;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return fallback;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function cdxRemainingClass(percent) {
|
|
1390
|
+
if (percent === null) {
|
|
1391
|
+
return "neutral";
|
|
1392
|
+
}
|
|
1393
|
+
if (percent <= 10) {
|
|
1394
|
+
return "bad";
|
|
1395
|
+
}
|
|
1396
|
+
if (percent <= 30) {
|
|
1397
|
+
return "warn";
|
|
1398
|
+
}
|
|
1399
|
+
return "ok";
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function sortCdxSessionsByRemaining(entries) {
|
|
1403
|
+
return [...entries].sort((left, right) => {
|
|
1404
|
+
const leftRemaining = cdxRemainingPct(left);
|
|
1405
|
+
const rightRemaining = cdxRemainingPct(right);
|
|
1406
|
+
if (leftRemaining === null && rightRemaining === null) {
|
|
1407
|
+
return 0;
|
|
1408
|
+
}
|
|
1409
|
+
if (leftRemaining === null) {
|
|
1410
|
+
return 1;
|
|
1411
|
+
}
|
|
1412
|
+
if (rightRemaining === null) {
|
|
1413
|
+
return -1;
|
|
1414
|
+
}
|
|
1415
|
+
return rightRemaining - leftRemaining;
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function formatCdxValue(key, value) {
|
|
1420
|
+
if (["reset_at", "resetAt", "resets_at", "resetsAt", "reset_5h_at", "reset5hAt", "reset_week_at", "resetWeekAt", "updated_at", "updatedAt"].includes(key)) {
|
|
1421
|
+
return formatCdxResetAt(value);
|
|
1422
|
+
}
|
|
1423
|
+
if (typeof value === "object") {
|
|
1424
|
+
return JSON.stringify(value);
|
|
1425
|
+
}
|
|
1426
|
+
return value;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function parseCdxDate(value) {
|
|
1430
|
+
const raw = String(value || "").trim();
|
|
1431
|
+
if (!raw) {
|
|
1432
|
+
return null;
|
|
1433
|
+
}
|
|
1434
|
+
const shortDate = raw.match(/^([A-Za-z]{3,})\s+(\d{1,2})\s+(\d{1,2}:\d{2})$/);
|
|
1435
|
+
if (shortDate) {
|
|
1436
|
+
const year = new Date().getFullYear();
|
|
1437
|
+
const timestamp = Date.parse(`${shortDate[1]} ${shortDate[2]} ${year} ${shortDate[3]}`);
|
|
1438
|
+
return Number.isFinite(timestamp) ? timestamp : null;
|
|
1439
|
+
}
|
|
1440
|
+
const timestamp = Date.parse(raw);
|
|
1441
|
+
if (Number.isFinite(timestamp)) {
|
|
1442
|
+
return timestamp;
|
|
1443
|
+
}
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function formatRelativeTime(timestamp) {
|
|
1448
|
+
const diffMs = timestamp - Date.now();
|
|
1449
|
+
const absMs = Math.abs(diffMs);
|
|
1450
|
+
const minutes = Math.round(absMs / 60000);
|
|
1451
|
+
if (minutes < 1) {
|
|
1452
|
+
return diffMs >= 0 ? "now" : "just now";
|
|
1453
|
+
}
|
|
1454
|
+
const hours = Math.floor(minutes / 60);
|
|
1455
|
+
const days = Math.floor(hours / 24);
|
|
1456
|
+
const remainingHours = hours % 24;
|
|
1457
|
+
const remainingMinutes = minutes % 60;
|
|
1458
|
+
let body = "";
|
|
1459
|
+
if (days > 0) {
|
|
1460
|
+
body = `${days}d${remainingHours > 0 ? ` ${remainingHours}h` : ""}`;
|
|
1461
|
+
} else if (hours > 0) {
|
|
1462
|
+
body = `${hours}h${remainingMinutes > 0 ? ` ${remainingMinutes}m` : ""}`;
|
|
1463
|
+
} else {
|
|
1464
|
+
body = `${minutes}m`;
|
|
1465
|
+
}
|
|
1466
|
+
return diffMs >= 0 ? `in ${body}` : `${body} ago`;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function formatCdxResetAt(value) {
|
|
1470
|
+
const raw = String(value || "").trim();
|
|
1471
|
+
if (!raw) {
|
|
1472
|
+
return "-";
|
|
1473
|
+
}
|
|
1474
|
+
const timestamp = parseCdxDate(raw);
|
|
1475
|
+
return timestamp === null ? raw : formatRelativeTime(timestamp);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function formatCdxCredits(value) {
|
|
1479
|
+
const text = String(value ?? "").trim();
|
|
1480
|
+
if (!text || text === "-") {
|
|
1481
|
+
return "-";
|
|
1482
|
+
}
|
|
1483
|
+
const number = Number(text);
|
|
1484
|
+
return Number.isFinite(number) ? number.toFixed(2) : text;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function renderCdxBadge(value, fallback = "reported") {
|
|
1488
|
+
const label = String(value || fallback || "reported");
|
|
1489
|
+
return `<span class="viewer-cdx__badge viewer-cdx__badge--${cdxStateClass(label)}">${escapeHtml(cdxLabel(label))}</span>`;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function cdxDetailEntries(item, excludedKeys) {
|
|
1493
|
+
return objectEntries(item)
|
|
1494
|
+
.filter(([key, value]) => !excludedKeys.includes(key) && value !== undefined && value !== null && value !== "")
|
|
1495
|
+
.slice(0, 6);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function renderCdxDetailPills(item, excludedKeys) {
|
|
1499
|
+
const details = cdxDetailEntries(item, excludedKeys).map(([key, value]) => `
|
|
1500
|
+
<span class="viewer-cdx__pill"><span>${escapeHtml(cdxLabel(key))}</span><strong>${escapeHtml(formatCdxValue(key, value))}</strong></span>
|
|
1501
|
+
`).join("");
|
|
1502
|
+
return details ? `<div class="viewer-cdx__pills">${details}</div>` : "";
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function renderCdxRemainingPill(item) {
|
|
1506
|
+
const percent = cdxRemainingPct(item);
|
|
1507
|
+
if (percent === null) {
|
|
1508
|
+
return "";
|
|
1509
|
+
}
|
|
1510
|
+
return `
|
|
1511
|
+
<span class="viewer-cdx__remaining viewer-cdx__remaining--${cdxRemainingClass(percent)}" title="${escapeHtml(percent)}% usage remaining">
|
|
1512
|
+
<span>Remaining</span>
|
|
1513
|
+
<strong>${escapeHtml(percent)}%</strong>
|
|
1514
|
+
</span>
|
|
1515
|
+
`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function cdxSessionBlock(item) {
|
|
1519
|
+
const explicit = cdxField(item, ["block", "blocked", "blocking"], "");
|
|
1520
|
+
if (explicit && explicit !== true) {
|
|
1521
|
+
return explicit;
|
|
1522
|
+
}
|
|
1523
|
+
const fiveHour = Number(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN));
|
|
1524
|
+
const week = Number(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN));
|
|
1525
|
+
if (Number.isFinite(fiveHour) && fiveHour <= 0) {
|
|
1526
|
+
return "5H";
|
|
1527
|
+
}
|
|
1528
|
+
if (Number.isFinite(week) && week <= 1) {
|
|
1529
|
+
return "WEEK";
|
|
1530
|
+
}
|
|
1531
|
+
return explicit === true ? "YES" : "-";
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function renderCdxSessionTable(sessions, emptyText) {
|
|
1535
|
+
if (!sessions.length) {
|
|
1536
|
+
return `<div class="viewer-cdx__empty">${escapeHtml(emptyText)}</div>`;
|
|
1537
|
+
}
|
|
1538
|
+
const rows = sessions.slice(0, 24).map((entry) => {
|
|
1539
|
+
const item = entry && typeof entry === "object" ? entry : { value: entry };
|
|
1540
|
+
const name = cdxField(item, ["session_name", "name", "id", "value"]);
|
|
1541
|
+
const sessionName = `${name}${item.active ? "*" : ""}`;
|
|
1542
|
+
const status = cdxField(item, ["status", "state"]);
|
|
1543
|
+
const auth = String(cdxField(item, ["auth_status", "authStatus"], "-")).replace("authenticated", "logged");
|
|
1544
|
+
const block = cdxSessionBlock(item);
|
|
1545
|
+
return `
|
|
1546
|
+
<tr>
|
|
1547
|
+
<td class="viewer-cdx__session-name">${escapeHtml(sessionName)}</td>
|
|
1548
|
+
<td>${escapeHtml(cdxField(item, ["provider"], "-"))}</td>
|
|
1549
|
+
<td>${renderCdxBadge(status)}</td>
|
|
1550
|
+
<td>${escapeHtml(auth)}</td>
|
|
1551
|
+
<td>${renderCdxRemainingPill(item) || escapeHtml(cdxPct(cdxField(item, ["available_pct", "availablePct"], NaN)))}</td>
|
|
1552
|
+
<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_5h_pct", "remaining5hPct"], NaN)))}</td>
|
|
1553
|
+
<td>${escapeHtml(cdxPct(cdxField(item, ["remaining_week_pct", "remainingWeekPct"], NaN)))}</td>
|
|
1554
|
+
<td>${escapeHtml(block)}</td>
|
|
1555
|
+
<td>${escapeHtml(formatCdxCredits(cdxField(item, ["credits", "cr"], "-")))}</td>
|
|
1556
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_5h_at", "reset5hAt", "reset_at", "resetAt"], "")))}</td>
|
|
1557
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["reset_week_at", "resetWeekAt", "reset_at", "resetAt"], "")))}</td>
|
|
1558
|
+
<td>${escapeHtml(formatCdxResetAt(cdxField(item, ["updated_at", "updatedAt"], "")))}</td>
|
|
1559
|
+
</tr>
|
|
1560
|
+
`;
|
|
1561
|
+
}).join("");
|
|
1562
|
+
return `
|
|
1563
|
+
<div class="viewer-cdx__table-wrap">
|
|
1564
|
+
<table class="viewer-cdx__table">
|
|
1565
|
+
<thead>
|
|
1566
|
+
<tr>
|
|
1567
|
+
<th>SESSION</th>
|
|
1568
|
+
<th>PROV.</th>
|
|
1569
|
+
<th>STATUS</th>
|
|
1570
|
+
<th>AUTH</th>
|
|
1571
|
+
<th>OK</th>
|
|
1572
|
+
<th>5H</th>
|
|
1573
|
+
<th>WEEK</th>
|
|
1574
|
+
<th>BLOCK</th>
|
|
1575
|
+
<th>CR</th>
|
|
1576
|
+
<th>RESET 5H</th>
|
|
1577
|
+
<th>RESET WEEK</th>
|
|
1578
|
+
<th>UPDATED</th>
|
|
1579
|
+
</tr>
|
|
1580
|
+
</thead>
|
|
1581
|
+
<tbody>${rows}</tbody>
|
|
1582
|
+
</table>
|
|
1583
|
+
</div>
|
|
1584
|
+
`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function renderCdxEntityRows(entries, emptyText, options = {}) {
|
|
1588
|
+
const titleKeys = options.titleKeys || ["name", "session_name", "id", "provider", "model", "value"];
|
|
1589
|
+
const stateKeys = options.stateKeys || ["state", "status", "readiness", "available", "auth_status"];
|
|
1590
|
+
const excludedKeys = [...titleKeys, ...stateKeys, "available_pct", "availablePct", "remaining_pct", "remainingPct", "lowest_available_pct", "lowestAvailablePct"];
|
|
1591
|
+
const rows = entries.slice(0, 16).map((entry) => {
|
|
1592
|
+
const item = entry && typeof entry === "object" ? entry : { value: entry };
|
|
1593
|
+
const name = titleKeys.map((key) => item[key]).find(Boolean) || "entry";
|
|
1594
|
+
const state = stateKeys.map((key) => item[key]).find((value) => value !== undefined && value !== null && value !== "") || "";
|
|
1595
|
+
const subtitle = options.subtitleKeys
|
|
1596
|
+
? options.subtitleKeys.map((key) => item[key]).filter(Boolean).join(" · ")
|
|
1597
|
+
: "";
|
|
1598
|
+
return `
|
|
1599
|
+
<li class="viewer-cdx__entity">
|
|
1600
|
+
<div class="viewer-cdx__entity-main">
|
|
1601
|
+
<div>
|
|
1602
|
+
<strong>${escapeHtml(name)}</strong>
|
|
1603
|
+
${subtitle ? `<div class="viewer-cdx__meta">${escapeHtml(subtitle)}</div>` : ""}
|
|
1604
|
+
</div>
|
|
1605
|
+
<div class="viewer-cdx__entity-status">
|
|
1606
|
+
${renderCdxRemainingPill(item)}
|
|
1607
|
+
${renderCdxBadge(state)}
|
|
1608
|
+
</div>
|
|
1609
|
+
</div>
|
|
1610
|
+
${renderCdxDetailPills(item, excludedKeys)}
|
|
1611
|
+
</li>
|
|
1612
|
+
`;
|
|
1613
|
+
}).join("");
|
|
1614
|
+
return rows || `<li class="viewer-cdx__empty">${escapeHtml(emptyText)}</li>`;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function renderCdxStatus(payload) {
|
|
1618
|
+
if (!payload || payload.state !== "ok") {
|
|
1619
|
+
return `
|
|
1620
|
+
<div class="viewer-cdx">
|
|
1621
|
+
<div class="viewer-cdx__state">${escapeHtml(payload?.message || "CDX status is unavailable.")}</div>
|
|
1622
|
+
</div>
|
|
1623
|
+
`;
|
|
1624
|
+
}
|
|
1625
|
+
const status = payload.status || {};
|
|
1626
|
+
const providers = cdxProviders(status);
|
|
1627
|
+
const sessions = cdxSessions(status);
|
|
1628
|
+
const readiness = cdxReadiness(status);
|
|
1629
|
+
const commands = pickFirstArray(status, ["nextCommands", "next_commands", "safeCommands", "safe_commands", "commands"])
|
|
1630
|
+
.map((entry) => typeof entry === "string" ? entry : (entry.command || entry.value || entry.name || ""))
|
|
1631
|
+
.filter(Boolean);
|
|
1632
|
+
if (!commands.length) {
|
|
1633
|
+
commands.push("cdx status --json");
|
|
1634
|
+
}
|
|
1635
|
+
const runtimeState = status.state || status.status || status.availability || "ok";
|
|
1636
|
+
const readinessCount = objectEntries(readiness).length;
|
|
1637
|
+
const cards = [
|
|
1638
|
+
["Runtime", runtimeState],
|
|
1639
|
+
["Providers", providers.length],
|
|
1640
|
+
["Sessions", sessions.length],
|
|
1641
|
+
["Readiness", readinessCount ? `${readinessCount} signals` : "Not reported"]
|
|
1642
|
+
].map(([label, value]) => `
|
|
1643
|
+
<div class="viewer-cdx__card">
|
|
1644
|
+
<div class="viewer-cdx__label">${escapeHtml(label)}</div>
|
|
1645
|
+
<div class="viewer-cdx__value">${label === "Runtime" ? renderCdxBadge(value) : escapeHtml(value)}</div>
|
|
1646
|
+
</div>
|
|
1647
|
+
`).join("");
|
|
1648
|
+
const commandRows = commands.slice(0, 10).map((command, index) => `
|
|
1649
|
+
<li>
|
|
1650
|
+
<span>${escapeHtml(index + 1)}</span>
|
|
1651
|
+
<code>${escapeHtml(command)}</code>
|
|
1652
|
+
</li>
|
|
1653
|
+
`).join("");
|
|
1654
|
+
return `
|
|
1655
|
+
<div class="viewer-cdx">
|
|
1656
|
+
<div class="viewer-cdx__summary">${cards}</div>
|
|
1657
|
+
<div class="viewer-cdx__workspace">
|
|
1658
|
+
<div class="viewer-cdx__stack">
|
|
1659
|
+
<section class="viewer-cdx__section">
|
|
1660
|
+
<h2 class="viewer-cdx__heading">Sessions</h2>
|
|
1661
|
+
${renderCdxSessionTable(sessions, "No sessions reported.")}
|
|
1662
|
+
</section>
|
|
1663
|
+
<section class="viewer-cdx__section">
|
|
1664
|
+
<h2 class="viewer-cdx__heading">Providers</h2>
|
|
1665
|
+
<ul class="viewer-cdx__list">${renderCdxEntityRows(providers, "No provider status reported.", { subtitleKeys: ["model"] })}</ul>
|
|
1666
|
+
</section>
|
|
1667
|
+
</div>
|
|
1668
|
+
<div class="viewer-cdx__stack">
|
|
1669
|
+
<section class="viewer-cdx__section">
|
|
1670
|
+
<h2 class="viewer-cdx__heading">Readiness and quota</h2>
|
|
1671
|
+
<ul class="viewer-cdx__list">${renderCdxObjectRows(readiness, "No readiness or quota details reported.")}</ul>
|
|
1672
|
+
</section>
|
|
1673
|
+
<section class="viewer-cdx__section">
|
|
1674
|
+
<h2 class="viewer-cdx__heading">Safe next commands</h2>
|
|
1675
|
+
<ul class="viewer-cdx__commands">${commandRows || '<li class="viewer-cdx__empty">No suggested commands reported.</li>'}</ul>
|
|
1676
|
+
</section>
|
|
1677
|
+
</div>
|
|
1678
|
+
</div>
|
|
1679
|
+
</div>
|
|
1680
|
+
`;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async function showCdxStatus(options = {}) {
|
|
1684
|
+
if (!options.silent) {
|
|
1685
|
+
setMeta("Checking CDX status...");
|
|
1686
|
+
}
|
|
1687
|
+
const response = await fetch("/api/cdx-status");
|
|
1688
|
+
let data = {};
|
|
1689
|
+
try {
|
|
1690
|
+
data = await response.json();
|
|
1691
|
+
} catch {
|
|
1692
|
+
data = {};
|
|
1693
|
+
}
|
|
1694
|
+
if (response.status === 404) {
|
|
1695
|
+
setDocument("CDX status", renderCdxStatus({
|
|
1696
|
+
state: "unavailable",
|
|
1697
|
+
message: "CDX status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
|
|
1698
|
+
}));
|
|
1699
|
+
setMeta("Restart the local viewer to enable CDX status.");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
if (!response.ok || !data.ok) {
|
|
1703
|
+
throw new Error(data.error || "Unable to load CDX status.");
|
|
1704
|
+
}
|
|
1705
|
+
setDocument("CDX status", renderCdxStatus(data.payload));
|
|
1706
|
+
setMeta(options.silent ? "CDX status refreshed." : "CDX status loaded.");
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function renderGitStatus(payload) {
|
|
1710
|
+
if (!payload || payload.state !== "ok") {
|
|
1711
|
+
return `
|
|
1712
|
+
<div class="viewer-git">
|
|
1713
|
+
<div class="viewer-git__state">${escapeHtml(payload?.message || "Git status is unavailable.")}</div>
|
|
1714
|
+
</div>
|
|
1715
|
+
`;
|
|
1716
|
+
}
|
|
1717
|
+
const counts = payload.counts || {};
|
|
1718
|
+
const stagedCount = Number(counts.staged || 0);
|
|
1719
|
+
const modifiedCount = Number(counts.modified || 0);
|
|
1720
|
+
const deletedCount = Number(counts.deleted || 0);
|
|
1721
|
+
const renamedCount = Number(counts.renamed || 0);
|
|
1722
|
+
const untrackedCount = Number(counts.untracked || 0);
|
|
1723
|
+
const summary = [
|
|
1724
|
+
["Branch", payload.branch || "HEAD"],
|
|
1725
|
+
["Tracking", payload.tracking || "None"],
|
|
1726
|
+
["Ahead", payload.ahead || 0],
|
|
1727
|
+
["Behind", payload.behind || 0],
|
|
1728
|
+
["State", payload.clean ? "Clean" : "Dirty"],
|
|
1729
|
+
["Staged", stagedCount],
|
|
1730
|
+
["Worktree", modifiedCount + deletedCount + renamedCount],
|
|
1731
|
+
["Untracked", untrackedCount]
|
|
1732
|
+
];
|
|
1733
|
+
const cards = renderMetricCards(summary);
|
|
1734
|
+
const groupDefs = [
|
|
1735
|
+
["staged", "Staged", "staged"],
|
|
1736
|
+
["modified", "Modified", "worktree"],
|
|
1737
|
+
["deleted", "Deleted", "worktree"],
|
|
1738
|
+
["renamed", "Renamed", "worktree"],
|
|
1739
|
+
["untracked", "Untracked", "untracked"]
|
|
1740
|
+
];
|
|
1741
|
+
const domainDefs = [
|
|
1742
|
+
["changes", "Changes", stagedCount + modifiedCount + deletedCount + renamedCount + untrackedCount],
|
|
1743
|
+
["staged", "Staged", stagedCount],
|
|
1744
|
+
["worktree", "Worktree", modifiedCount + deletedCount + renamedCount],
|
|
1745
|
+
["untracked", "Untracked", untrackedCount],
|
|
1746
|
+
["history", "History", Array.isArray(payload.recentCommits) ? payload.recentCommits.length : (payload.latestCommit ? 1 : 0)],
|
|
1747
|
+
["remote", "Remote", payload.tracking ? 1 : 0]
|
|
1748
|
+
];
|
|
1749
|
+
const domains = domainDefs.map(([key, label, count], index) => `
|
|
1750
|
+
<button class="viewer-git__domain${index === 0 ? " is-active" : ""}" type="button" data-viewer-git-domain="${escapeHtml(key)}" aria-pressed="${index === 0 ? "true" : "false"}">
|
|
1751
|
+
<span class="viewer-git__domain-label">${escapeHtml(label)}${key === "changes" ? gitBadgeHtml("changes") : ""}${key === "history" ? gitBadgeHtml("history") : ""}</span><strong>${escapeHtml(count)}</strong>
|
|
1752
|
+
</button>
|
|
1753
|
+
`).join("");
|
|
1754
|
+
const renderFileSections = (allowedKeys) => groupDefs.filter(([key]) => allowedKeys.includes(key)).map(([key, label]) => {
|
|
1755
|
+
const entries = Array.isArray(payload.groups?.[key]) ? payload.groups[key] : [];
|
|
1756
|
+
if (!entries.length) {
|
|
1757
|
+
return "";
|
|
1758
|
+
}
|
|
1759
|
+
return `
|
|
1760
|
+
<section class="viewer-git__section">
|
|
1761
|
+
<h2>${escapeHtml(label)}</h2>
|
|
1762
|
+
<ul class="viewer-git__files">${entries.map((entry) => `
|
|
1763
|
+
<li>
|
|
1764
|
+
<button class="viewer-git__file" type="button" data-viewer-git-file="${escapeHtml(entry.path)}" data-viewer-git-cached="${key === "staged" ? "1" : "0"}">
|
|
1765
|
+
<span class="viewer-git__file-path">${escapeHtml(entry.from ? `${entry.from} -> ${entry.path}` : entry.path)}</span>
|
|
1766
|
+
${entry.logicsType ? `<span class="viewer-git__file-kind">${escapeHtml(entry.logicsType)}</span>` : ""}
|
|
1767
|
+
</button>
|
|
1768
|
+
</li>
|
|
1769
|
+
`).join("")}</ul>
|
|
1770
|
+
</section>
|
|
1771
|
+
`;
|
|
1772
|
+
}).join("");
|
|
1773
|
+
const changesSections = renderFileSections(["staged", "modified", "deleted", "renamed", "untracked"]);
|
|
1774
|
+
const stagedSections = renderFileSections(["staged"]);
|
|
1775
|
+
const worktreeSections = renderFileSections(["modified", "deleted", "renamed"]);
|
|
1776
|
+
const untrackedSections = renderFileSections(["untracked"]);
|
|
1777
|
+
const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
|
|
1778
|
+
const recentCommits = Array.isArray(payload.recentCommits) ? payload.recentCommits : [];
|
|
1779
|
+
const historyRows = recentCommits.length
|
|
1780
|
+
? recentCommits.map((commit) => `
|
|
1781
|
+
<li class="viewer-git__commit-row">
|
|
1782
|
+
<div class="viewer-git__commit-main">
|
|
1783
|
+
<code>${escapeHtml(commit.hash || "")}</code>
|
|
1784
|
+
<strong>${escapeHtml(commit.subject || "Untitled commit")}</strong>
|
|
1785
|
+
</div>
|
|
1786
|
+
<div class="viewer-git__commit-meta">
|
|
1787
|
+
<span>${escapeHtml([commit.author, commit.date].filter(Boolean).join(" · ") || "Unknown")}</span>
|
|
1788
|
+
${commit.refs ? `<span class="viewer-git__commit-refs">${escapeHtml(commit.refs)}</span>` : ""}
|
|
1789
|
+
</div>
|
|
1790
|
+
</li>
|
|
1791
|
+
`).join("")
|
|
1792
|
+
: `<li class="viewer-git__commit-row">${escapeHtml(payload.latestCommit || "No commit history available.")}</li>`;
|
|
1793
|
+
const history = `
|
|
1794
|
+
<section class="viewer-git__section">
|
|
1795
|
+
<h2>History</h2>
|
|
1796
|
+
<ul class="viewer-git__commits">${historyRows}</ul>
|
|
1797
|
+
</section>
|
|
1798
|
+
`;
|
|
1799
|
+
const remote = `
|
|
1800
|
+
<section class="viewer-git__section">
|
|
1801
|
+
<h2>Remote</h2>
|
|
1802
|
+
<p class="viewer-git__state">${escapeHtml(payload.tracking ? `Tracking ${payload.tracking}` : "No upstream branch detected.")}</p>
|
|
1803
|
+
<p class="viewer-git__state">${escapeHtml(`Ahead ${payload.ahead || 0}, behind ${payload.behind || 0}`)}</p>
|
|
1804
|
+
</section>
|
|
1805
|
+
`;
|
|
1806
|
+
return `
|
|
1807
|
+
<div class="viewer-git">
|
|
1808
|
+
<div class="viewer-git__summary">${cards}</div>
|
|
1809
|
+
<div class="viewer-git__workspace">
|
|
1810
|
+
<nav class="viewer-git__domains" aria-label="Git domains">${domains}</nav>
|
|
1811
|
+
<div class="viewer-git__content" aria-label="Git domain content">
|
|
1812
|
+
<section class="viewer-git__panel" data-viewer-git-panel="changes">
|
|
1813
|
+
<header class="viewer-git__panel-header"><span>Changes</span><strong>${escapeHtml(stagedCount + modifiedCount + deletedCount + renamedCount + untrackedCount)} files</strong></header>
|
|
1814
|
+
${clean}
|
|
1815
|
+
${changesSections || '<p class="viewer-git__state">No file changes detected.</p>'}
|
|
1816
|
+
</section>
|
|
1817
|
+
<section class="viewer-git__panel" data-viewer-git-panel="staged" hidden>
|
|
1818
|
+
<header class="viewer-git__panel-header"><span>Staged</span><strong>${escapeHtml(stagedCount)} files</strong></header>
|
|
1819
|
+
${stagedSections || '<p class="viewer-git__state">No staged files.</p>'}
|
|
1820
|
+
</section>
|
|
1821
|
+
<section class="viewer-git__panel" data-viewer-git-panel="worktree" hidden>
|
|
1822
|
+
<header class="viewer-git__panel-header"><span>Worktree</span><strong>${escapeHtml(modifiedCount + deletedCount + renamedCount)} files</strong></header>
|
|
1823
|
+
${worktreeSections || '<p class="viewer-git__state">No modified, deleted, or renamed files.</p>'}
|
|
1824
|
+
</section>
|
|
1825
|
+
<section class="viewer-git__panel" data-viewer-git-panel="untracked" hidden>
|
|
1826
|
+
<header class="viewer-git__panel-header"><span>Untracked</span><strong>${escapeHtml(untrackedCount)} files</strong></header>
|
|
1827
|
+
${untrackedSections || '<p class="viewer-git__state">No untracked files.</p>'}
|
|
1828
|
+
</section>
|
|
1829
|
+
<section class="viewer-git__panel" data-viewer-git-panel="history" hidden>
|
|
1830
|
+
<header class="viewer-git__panel-header"><span>History</span><strong>${escapeHtml(recentCommits.length || (payload.latestCommit ? 1 : 0))} commits</strong></header>
|
|
1831
|
+
${history}
|
|
1832
|
+
</section>
|
|
1833
|
+
<section class="viewer-git__panel" data-viewer-git-panel="remote" hidden>
|
|
1834
|
+
<header class="viewer-git__panel-header"><span>Remote</span><strong>${escapeHtml(payload.tracking || "none")}</strong></header>
|
|
1835
|
+
${remote}
|
|
1836
|
+
</section>
|
|
1837
|
+
</div>
|
|
1838
|
+
<section class="viewer-git__detail" aria-label="Git diff">
|
|
1839
|
+
<div class="viewer-git__detail-title">Diff preview</div>
|
|
1840
|
+
<div class="viewer-git__diff" data-viewer-git-diff>Select a changed file to preview its diff.</div>
|
|
1841
|
+
</section>
|
|
1842
|
+
</div>
|
|
1843
|
+
</div>
|
|
1844
|
+
`;
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function setActiveGitFile(button) {
|
|
1848
|
+
document.querySelectorAll("[data-viewer-git-file]").forEach((node) => {
|
|
1849
|
+
if (node instanceof HTMLElement) {
|
|
1850
|
+
node.classList.toggle("is-active", node === button);
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async function loadGitDiff(path, cached, button = null) {
|
|
1856
|
+
const diffPanel = document.querySelector("[data-viewer-git-diff]");
|
|
1857
|
+
if (!(diffPanel instanceof HTMLElement) || !path) {
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
if (button instanceof HTMLElement) {
|
|
1861
|
+
setActiveGitFile(button);
|
|
1862
|
+
}
|
|
1863
|
+
diffPanel.textContent = "Loading diff...";
|
|
1864
|
+
const params = new URLSearchParams({ path });
|
|
1865
|
+
if (cached) {
|
|
1866
|
+
params.set("cached", "1");
|
|
1867
|
+
}
|
|
1868
|
+
const response = await fetch(`/api/git-diff?${params.toString()}`);
|
|
1869
|
+
const data = await response.json();
|
|
1870
|
+
const payload = data.payload || {};
|
|
1871
|
+
if (!response.ok || !data.ok || payload.state !== "ok") {
|
|
1872
|
+
diffPanel.textContent = payload.message || data.error || "Unable to load diff.";
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
const content = payload.diff || payload.message || "No diff is available for this file.";
|
|
1876
|
+
diffPanel.innerHTML = `<div class="viewer-git__diff-meta">${escapeHtml(payload.path || path)} · ${escapeHtml(payload.mode || "worktree")}${payload.truncated ? " · truncated" : ""}</div><pre><code>${escapeHtml(content)}</code></pre>`;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function applyGitDomain(domain) {
|
|
1880
|
+
const selected = domain || "changes";
|
|
1881
|
+
document.querySelectorAll(".viewer-git__domain[data-viewer-git-domain]").forEach((node) => {
|
|
1882
|
+
if (node instanceof HTMLElement) {
|
|
1883
|
+
const active = node.getAttribute("data-viewer-git-domain") === selected;
|
|
1884
|
+
node.classList.toggle("is-active", active);
|
|
1885
|
+
node.setAttribute("aria-pressed", active ? "true" : "false");
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
document.querySelectorAll("[data-viewer-git-panel]").forEach((node) => {
|
|
1889
|
+
if (node instanceof HTMLElement) {
|
|
1890
|
+
node.hidden = node.getAttribute("data-viewer-git-panel") !== selected;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function currentGitViewState() {
|
|
1896
|
+
const activeDomain = document.querySelector(".viewer-git__domain.is-active[data-viewer-git-domain]");
|
|
1897
|
+
const activeFile = document.querySelector(".viewer-git__file.is-active[data-viewer-git-file]");
|
|
1898
|
+
return {
|
|
1899
|
+
domain: activeDomain instanceof HTMLElement ? activeDomain.getAttribute("data-viewer-git-domain") || "changes" : "changes",
|
|
1900
|
+
path: activeFile instanceof HTMLElement ? activeFile.getAttribute("data-viewer-git-file") || "" : "",
|
|
1901
|
+
cached: activeFile instanceof HTMLElement && activeFile.getAttribute("data-viewer-git-cached") === "1",
|
|
1902
|
+
};
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function findGitFileButton(path, cached) {
|
|
1906
|
+
return Array.from(document.querySelectorAll("[data-viewer-git-file]")).find((node) => (
|
|
1907
|
+
node instanceof HTMLElement &&
|
|
1908
|
+
node.getAttribute("data-viewer-git-file") === path &&
|
|
1909
|
+
(node.getAttribute("data-viewer-git-cached") === "1") === Boolean(cached)
|
|
1910
|
+
)) || null;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
async function showGitStatus(options = {}) {
|
|
1914
|
+
const previous = options.preserve ? currentGitViewState() : { domain: "changes", path: "", cached: false };
|
|
1915
|
+
if (!options.silent) {
|
|
1916
|
+
setMeta("Checking Git status...");
|
|
1917
|
+
}
|
|
1918
|
+
const response = await fetch("/api/git-status");
|
|
1919
|
+
let data = {};
|
|
1920
|
+
try {
|
|
1921
|
+
data = await response.json();
|
|
1922
|
+
} catch {
|
|
1923
|
+
data = {};
|
|
1924
|
+
}
|
|
1925
|
+
if (response.status === 404) {
|
|
1926
|
+
setDocument("Git status", renderGitStatus({
|
|
1927
|
+
state: "unavailable",
|
|
1928
|
+
message: "Git status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
|
|
1929
|
+
}));
|
|
1930
|
+
setMeta("Restart the local viewer to enable Git status.");
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
if (!response.ok || !data.ok) {
|
|
1934
|
+
throw new Error(data.error || "Unable to load Git status.");
|
|
1935
|
+
}
|
|
1936
|
+
setGitBadgeCountsFromPayload(data.payload, { updateMain: false });
|
|
1937
|
+
updateMainGitBadges();
|
|
1938
|
+
setDocument("Git status", renderGitStatus(data.payload));
|
|
1939
|
+
applyGitDomain(previous.domain || "changes");
|
|
1940
|
+
const restoredFile = previous.path ? findGitFileButton(previous.path, previous.cached) : null;
|
|
1941
|
+
const firstFile = restoredFile || document.querySelector("[data-viewer-git-file]");
|
|
1942
|
+
if (firstFile instanceof HTMLElement) {
|
|
1943
|
+
await loadGitDiff(firstFile.getAttribute("data-viewer-git-file") || "", firstFile.getAttribute("data-viewer-git-cached") === "1", firstFile);
|
|
1944
|
+
}
|
|
1945
|
+
setMeta(options.silent ? "Git status refreshed." : "Git status loaded.");
|
|
1946
|
+
}
|
|
1947
|
+
|
|
747
1948
|
window.acquireVsCodeApi = function acquireVsCodeApi() {
|
|
748
1949
|
return {
|
|
749
1950
|
postMessage(message) {
|
|
@@ -755,7 +1956,7 @@
|
|
|
755
1956
|
return;
|
|
756
1957
|
}
|
|
757
1958
|
if (message.type === "refresh") {
|
|
758
|
-
|
|
1959
|
+
refreshViewer("POST").catch((error) => setMeta(error.message));
|
|
759
1960
|
return;
|
|
760
1961
|
}
|
|
761
1962
|
if (message.type === "open" || message.type === "read") {
|
|
@@ -786,22 +1987,68 @@
|
|
|
786
1987
|
setControlValue("show-companion-docs", true, "change");
|
|
787
1988
|
setControlValue("hide-empty-columns", true, "change");
|
|
788
1989
|
applyLocalViewerChrome();
|
|
789
|
-
[document.getElementById("viewer-insights")
|
|
1990
|
+
[document.getElementById("viewer-insights")].forEach((button) => {
|
|
790
1991
|
button?.addEventListener("click", () => {
|
|
791
|
-
showCorpusInsights();
|
|
1992
|
+
showCorpusInsights().catch((error) => setMeta(error.message));
|
|
1993
|
+
});
|
|
1994
|
+
});
|
|
1995
|
+
const autoControl = autoRefreshControl();
|
|
1996
|
+
if (autoControl instanceof HTMLInputElement) {
|
|
1997
|
+
autoControl.addEventListener("change", () => {
|
|
1998
|
+
setAutoRefreshEnabled(autoControl.checked);
|
|
1999
|
+
});
|
|
2000
|
+
setAutoRefreshEnabled(autoControl.checked);
|
|
2001
|
+
}
|
|
2002
|
+
const intervalControl = refreshIntervalControl();
|
|
2003
|
+
if (intervalControl instanceof HTMLSelectElement) {
|
|
2004
|
+
updateRefreshIntervalControl();
|
|
2005
|
+
intervalControl.addEventListener("change", () => {
|
|
2006
|
+
setAutoRefreshIntervalSeconds(intervalControl.value, { user: true });
|
|
792
2007
|
});
|
|
2008
|
+
}
|
|
2009
|
+
bindRefreshMenuControls();
|
|
2010
|
+
document.addEventListener("click", (event) => {
|
|
2011
|
+
const target = event.target;
|
|
2012
|
+
const button = refreshMenuButton();
|
|
2013
|
+
const panel = refreshMenuPanel();
|
|
2014
|
+
try {
|
|
2015
|
+
if (target && (
|
|
2016
|
+
button?.contains(target) ||
|
|
2017
|
+
panel?.contains(target)
|
|
2018
|
+
)) {
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
} catch {
|
|
2022
|
+
// Ignore non-node event targets and close the menu below.
|
|
2023
|
+
}
|
|
2024
|
+
setRefreshMenuOpen(false);
|
|
2025
|
+
});
|
|
2026
|
+
document.addEventListener("keydown", (event) => {
|
|
2027
|
+
if (event.key === "Escape") {
|
|
2028
|
+
setRefreshMenuOpen(false);
|
|
2029
|
+
}
|
|
793
2030
|
});
|
|
794
2031
|
document.querySelectorAll('[data-action="refresh"]').forEach((element) => {
|
|
795
2032
|
if (!(element instanceof HTMLElement)) {
|
|
796
2033
|
return;
|
|
797
2034
|
}
|
|
798
2035
|
element.addEventListener("click", () => {
|
|
799
|
-
|
|
2036
|
+
setRefreshMenuOpen(false);
|
|
2037
|
+
refreshViewer("POST").catch((error) => setMeta(error.message));
|
|
800
2038
|
});
|
|
801
2039
|
});
|
|
802
2040
|
document.getElementById("viewer-health")?.addEventListener("click", () => {
|
|
803
2041
|
showHealth().catch((error) => setMeta(error.message));
|
|
804
2042
|
});
|
|
2043
|
+
document.getElementById("viewer-git")?.addEventListener("click", () => {
|
|
2044
|
+
showGitStatus().catch((error) => setMeta(error.message));
|
|
2045
|
+
});
|
|
2046
|
+
document.getElementById("viewer-cdx")?.addEventListener("click", () => {
|
|
2047
|
+
showCdxStatus().catch((error) => setMeta(error.message));
|
|
2048
|
+
});
|
|
2049
|
+
activityClearControl()?.addEventListener("click", () => {
|
|
2050
|
+
clearActivityHistory();
|
|
2051
|
+
});
|
|
805
2052
|
document.querySelectorAll("[data-viewer-filter-group]").forEach((element) => {
|
|
806
2053
|
if (element instanceof HTMLSelectElement) {
|
|
807
2054
|
element.addEventListener("change", () => {
|
|
@@ -828,10 +2075,44 @@
|
|
|
828
2075
|
document.addEventListener("click", (event) => {
|
|
829
2076
|
window.setTimeout(() => applyLocalViewerChrome(), 0);
|
|
830
2077
|
const target = event.target instanceof Element ? event.target.closest("[data-viewer-doc-path]") : null;
|
|
831
|
-
|
|
2078
|
+
const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
|
|
2079
|
+
const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
|
|
2080
|
+
const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
|
|
2081
|
+
const gitDomainTarget = event.target instanceof Element ? event.target.closest(".viewer-git__domain[data-viewer-git-domain]") : null;
|
|
2082
|
+
const gitFileTarget = event.target instanceof Element ? event.target.closest("[data-viewer-git-file]") : null;
|
|
2083
|
+
if (revealTarget instanceof HTMLElement) {
|
|
2084
|
+
const list = revealTarget.closest("ul");
|
|
2085
|
+
list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {
|
|
2086
|
+
if (row instanceof HTMLElement) {
|
|
2087
|
+
row.hidden = false;
|
|
2088
|
+
row.removeAttribute("data-viewer-hidden-row");
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
revealTarget.closest("li")?.remove();
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (gitDomainTarget instanceof HTMLElement) {
|
|
2095
|
+
applyGitDomain(gitDomainTarget.getAttribute("data-viewer-git-domain") || "changes");
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if (gitFileTarget instanceof HTMLElement) {
|
|
2099
|
+
loadGitDiff(
|
|
2100
|
+
gitFileTarget.getAttribute("data-viewer-git-file") || "",
|
|
2101
|
+
gitFileTarget.getAttribute("data-viewer-git-cached") === "1",
|
|
2102
|
+
gitFileTarget
|
|
2103
|
+
).catch((error) => setMeta(error.message));
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
if (healthTarget instanceof HTMLElement) {
|
|
2107
|
+
showHealth().catch((error) => setMeta(error.message));
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
if (filterTarget instanceof HTMLElement) {
|
|
2111
|
+
applyViewerFilter(filterTarget.getAttribute("data-viewer-filter-group") || "", filterTarget.getAttribute("data-viewer-filter-value") || "");
|
|
2112
|
+
setMeta("Insight filter applied. Clear filters restores the normal viewer view.");
|
|
832
2113
|
return;
|
|
833
2114
|
}
|
|
834
|
-
const path = target.getAttribute("data-viewer-doc-path");
|
|
2115
|
+
const path = target instanceof HTMLElement ? target.getAttribute("data-viewer-doc-path") : "";
|
|
835
2116
|
if (path) {
|
|
836
2117
|
showDocumentByPath(path).catch((error) => setMeta(error.message));
|
|
837
2118
|
}
|