@grifhinz/logics-manager 2.3.2 → 2.4.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 +1 -1
- package/VERSION +1 -1
- package/clients/shared-web/media/css/toolbar.css +41 -3
- package/clients/shared-web/media/webviewChrome.js +31 -8
- package/clients/shared-web/media/webviewSelectors.js +8 -7
- package/clients/viewer/browser-host.js +463 -44
- package/clients/viewer/index.html +14 -28
- package/clients/viewer/viewer.css +141 -0
- package/logics_manager/viewer.py +213 -11
- package/package.json +4 -2
- package/pyproject.toml +17 -2
|
@@ -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,11 @@
|
|
|
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 activityClearControl = () => document.getElementById("activity-clear");
|
|
15
|
+
const activityStorageLimit = 80;
|
|
16
|
+
const defaultAutoRefreshIntervalMs = 60 * 1000;
|
|
13
17
|
const defaultFilterState = {
|
|
14
18
|
focus: "active",
|
|
15
19
|
type: "all",
|
|
@@ -19,6 +23,12 @@
|
|
|
19
23
|
};
|
|
20
24
|
let viewerFilterState = { ...defaultFilterState };
|
|
21
25
|
let latestItems = [];
|
|
26
|
+
let latestRepoRoot = "";
|
|
27
|
+
let latestMetaText = "Read-only local viewer";
|
|
28
|
+
let autoRefreshIntervalMs = defaultAutoRefreshIntervalMs;
|
|
29
|
+
let nextAutoRefreshAt = 0;
|
|
30
|
+
let autoRefreshEnabled = true;
|
|
31
|
+
let autoRefreshTimeoutId = 0;
|
|
22
32
|
let applyingLocalChrome = false;
|
|
23
33
|
let autoRefreshStarted = false;
|
|
24
34
|
let itemsLoadInFlight = false;
|
|
@@ -62,6 +72,52 @@
|
|
|
62
72
|
writeStoredState({ ...nextState, viewerFilterState: { ...viewerFilterState } });
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
function updateStoredActivity(nextItems) {
|
|
76
|
+
const storedState = readStoredState();
|
|
77
|
+
const baseState = storedState && typeof storedState === "object" ? storedState : {};
|
|
78
|
+
const previousSnapshot = baseState.activitySnapshot && typeof baseState.activitySnapshot === "object"
|
|
79
|
+
? baseState.activitySnapshot
|
|
80
|
+
: {};
|
|
81
|
+
const history = Array.isArray(baseState.activityHistory) ? [...baseState.activityHistory] : [];
|
|
82
|
+
const nextSnapshot = {};
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const decorated = nextItems.map((item) => {
|
|
85
|
+
const relPath = String(item.relPath || item.path || item.id || "");
|
|
86
|
+
const status = String(item?.indicators?.Status || "").trim();
|
|
87
|
+
if (relPath) {
|
|
88
|
+
nextSnapshot[relPath] = { status, updatedAt: item.updatedAt || "" };
|
|
89
|
+
}
|
|
90
|
+
const previous = relPath ? previousSnapshot[relPath] : null;
|
|
91
|
+
const previousStatus = String(previous?.status || "").trim();
|
|
92
|
+
const statusChanged = Boolean(previousStatus && status && previousStatus !== status);
|
|
93
|
+
if (relPath && (statusChanged || !previous)) {
|
|
94
|
+
history.unshift({ path: relPath, at: now, status, previousStatus, type: statusChanged ? "status-change" : "updated" });
|
|
95
|
+
}
|
|
96
|
+
return statusChanged ? { ...item, activityType: "status-change" } : item;
|
|
97
|
+
});
|
|
98
|
+
writeStoredState({
|
|
99
|
+
...baseState,
|
|
100
|
+
viewerFilterState: { ...viewerFilterState },
|
|
101
|
+
activitySnapshot: nextSnapshot,
|
|
102
|
+
activityHistory: history.slice(0, activityStorageLimit)
|
|
103
|
+
});
|
|
104
|
+
return decorated;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function clearActivityHistory() {
|
|
108
|
+
const storedState = readStoredState();
|
|
109
|
+
const nextState = storedState && typeof storedState === "object" ? { ...storedState } : {};
|
|
110
|
+
delete nextState.activitySnapshot;
|
|
111
|
+
delete nextState.activityHistory;
|
|
112
|
+
writeStoredState(nextState);
|
|
113
|
+
latestItems = latestItems.map((item) => {
|
|
114
|
+
const clone = { ...item };
|
|
115
|
+
delete clone.activityType;
|
|
116
|
+
return clone;
|
|
117
|
+
});
|
|
118
|
+
setMeta("Local activity history cleared.");
|
|
119
|
+
}
|
|
120
|
+
|
|
65
121
|
function markdownApi() {
|
|
66
122
|
if (typeof window.createCdxLogicsMarkdownApi === "function") {
|
|
67
123
|
return window.createCdxLogicsMarkdownApi();
|
|
@@ -90,12 +146,45 @@
|
|
|
90
146
|
}
|
|
91
147
|
|
|
92
148
|
function setMeta(text) {
|
|
149
|
+
latestMetaText = text;
|
|
150
|
+
renderMeta();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderMeta() {
|
|
93
154
|
const node = meta();
|
|
94
155
|
if (node) {
|
|
95
|
-
|
|
156
|
+
const parts = [latestMetaText];
|
|
157
|
+
if (autoRefreshEnabled && nextAutoRefreshAt > 0) {
|
|
158
|
+
const seconds = Math.max(0, Math.ceil((nextAutoRefreshAt - Date.now()) / 1000));
|
|
159
|
+
parts.push(`next auto refresh in ${seconds}s`);
|
|
160
|
+
}
|
|
161
|
+
node.textContent = parts.join(" · ");
|
|
96
162
|
}
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
function scheduleNextAutoRefresh() {
|
|
166
|
+
if (autoRefreshTimeoutId) {
|
|
167
|
+
window.clearTimeout(autoRefreshTimeoutId);
|
|
168
|
+
autoRefreshTimeoutId = 0;
|
|
169
|
+
}
|
|
170
|
+
nextAutoRefreshAt = autoRefreshEnabled ? Date.now() + autoRefreshIntervalMs : 0;
|
|
171
|
+
if (autoRefreshEnabled) {
|
|
172
|
+
autoRefreshTimeoutId = window.setTimeout(autoRefreshItems, autoRefreshIntervalMs);
|
|
173
|
+
}
|
|
174
|
+
renderMeta();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function updateRepositoryIdentity(payload) {
|
|
178
|
+
latestRepoRoot = String(payload.root || latestRepoRoot || "");
|
|
179
|
+
const pill = repoPill();
|
|
180
|
+
if (!pill) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const repoName = String(payload.repoName || latestRepoRoot.split(/[\\/]/).filter(Boolean).pop() || "repository");
|
|
184
|
+
pill.textContent = repoName;
|
|
185
|
+
pill.title = latestRepoRoot || repoName;
|
|
186
|
+
}
|
|
187
|
+
|
|
99
188
|
function findItemByPath(relPath) {
|
|
100
189
|
const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\//, "");
|
|
101
190
|
return latestItems.find((entry) => entry.relPath === normalized || entry.path === normalized) || null;
|
|
@@ -333,13 +422,20 @@
|
|
|
333
422
|
}
|
|
334
423
|
|
|
335
424
|
function postToApp(payload, options = {}) {
|
|
336
|
-
latestItems = Array.isArray(payload.items) ? payload.items : [];
|
|
337
|
-
const
|
|
425
|
+
latestItems = updateStoredActivity(Array.isArray(payload.items) ? payload.items : []);
|
|
426
|
+
const intervalSeconds = Number(payload.autoRefreshIntervalSeconds);
|
|
427
|
+
autoRefreshIntervalMs = Number.isFinite(intervalSeconds) && intervalSeconds > 0
|
|
428
|
+
? intervalSeconds * 1000
|
|
429
|
+
: defaultAutoRefreshIntervalMs;
|
|
430
|
+
updateRepositoryIdentity(payload);
|
|
431
|
+
const payloadWithActivity = { ...payload, items: latestItems };
|
|
432
|
+
const nextPayload = options.silent ? payloadWithActivity : applyFocusRequest(payloadWithActivity);
|
|
338
433
|
window.dispatchEvent(new MessageEvent("message", { data: { type: "data", payload: nextPayload } }));
|
|
339
434
|
const rootName = payload.root ? payload.root.split(/[\\/]/).filter(Boolean).pop() : "repository";
|
|
340
435
|
if (!options.silent) {
|
|
341
436
|
setMeta(`${rootName} · ${payload.items.length} docs · refreshed ${new Date().toLocaleTimeString()}`);
|
|
342
437
|
}
|
|
438
|
+
scheduleNextAutoRefresh();
|
|
343
439
|
renderUpdateNotice(payload.updateInfo);
|
|
344
440
|
updateFilterSummary();
|
|
345
441
|
applyLocalViewerChrome();
|
|
@@ -387,6 +483,9 @@
|
|
|
387
483
|
}
|
|
388
484
|
|
|
389
485
|
function autoRefreshItems() {
|
|
486
|
+
if (!autoRefreshEnabled) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
390
489
|
if (document.hidden) {
|
|
391
490
|
refreshAfterVisible = true;
|
|
392
491
|
return;
|
|
@@ -399,7 +498,9 @@
|
|
|
399
498
|
return;
|
|
400
499
|
}
|
|
401
500
|
autoRefreshStarted = true;
|
|
402
|
-
window.setInterval(
|
|
501
|
+
window.setInterval(() => {
|
|
502
|
+
renderMeta();
|
|
503
|
+
}, 1000);
|
|
403
504
|
document.addEventListener("visibilitychange", () => {
|
|
404
505
|
if (!document.hidden && refreshAfterVisible) {
|
|
405
506
|
refreshAfterVisible = false;
|
|
@@ -408,6 +509,15 @@
|
|
|
408
509
|
});
|
|
409
510
|
}
|
|
410
511
|
|
|
512
|
+
function setAutoRefreshEnabled(enabled) {
|
|
513
|
+
autoRefreshEnabled = Boolean(enabled);
|
|
514
|
+
const control = autoRefreshControl();
|
|
515
|
+
if (control instanceof HTMLInputElement) {
|
|
516
|
+
control.checked = autoRefreshEnabled;
|
|
517
|
+
}
|
|
518
|
+
scheduleNextAutoRefresh();
|
|
519
|
+
}
|
|
520
|
+
|
|
411
521
|
function statusValue(item) {
|
|
412
522
|
return String(item?.indicators?.Status || "").toLowerCase();
|
|
413
523
|
}
|
|
@@ -435,6 +545,37 @@
|
|
|
435
545
|
return timestamp > 0 && timestamp < Date.now() - 30 * 24 * 60 * 60 * 1000 && !isClosed(item);
|
|
436
546
|
}
|
|
437
547
|
|
|
548
|
+
function isRecent(item, days = 7) {
|
|
549
|
+
return updatedWithin(item, days);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function hasMissingOrAmbiguousStatus(item) {
|
|
553
|
+
const rawStatus = String(item?.indicators?.Status || "").trim();
|
|
554
|
+
if (!rawStatus) {
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
const normalized = rawStatus.toLowerCase();
|
|
558
|
+
return !["draft", "ready", "in progress", "blocked", "done", "archived", "obsolete"].includes(normalized);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function isSafeLogicsDocPath(value) {
|
|
562
|
+
const path = String(value || "").replace(/\\/g, "/").replace(/^\.?\//, "").trim();
|
|
563
|
+
if (!path || path.startsWith("/") || path.startsWith("~") || /^[A-Za-z]:/.test(path)) {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
if (path.split("/").includes("..") || !path.endsWith(".md")) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
return [
|
|
570
|
+
"logics/request/",
|
|
571
|
+
"logics/backlog/",
|
|
572
|
+
"logics/tasks/",
|
|
573
|
+
"logics/product/",
|
|
574
|
+
"logics/architecture/",
|
|
575
|
+
"logics/specs/"
|
|
576
|
+
].some((prefix) => path.startsWith(prefix));
|
|
577
|
+
}
|
|
578
|
+
|
|
438
579
|
function matchesViewerFilter(item) {
|
|
439
580
|
if (!item) {
|
|
440
581
|
return false;
|
|
@@ -562,58 +703,233 @@
|
|
|
562
703
|
count.textContent = `${visibleCount} of ${latestItems.length} docs shown${suffix}`;
|
|
563
704
|
}
|
|
564
705
|
|
|
565
|
-
function
|
|
566
|
-
|
|
567
|
-
|
|
706
|
+
function countBy(items, selector) {
|
|
707
|
+
return items.reduce((acc, item) => {
|
|
708
|
+
const key = selector(item) || "unknown";
|
|
709
|
+
acc[key] = (acc[key] || 0) + 1;
|
|
568
710
|
return acc;
|
|
569
711
|
}, {});
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const cards = [
|
|
575
|
-
["Docs", latestItems.length],
|
|
576
|
-
["Active", active],
|
|
577
|
-
["Blocked", blocked],
|
|
578
|
-
["Unlinked", unlinked]
|
|
579
|
-
].map(([label, value]) => `
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function renderMetricCards(entries) {
|
|
715
|
+
return entries.map(([label, value]) => `
|
|
580
716
|
<div class="viewer-insights__card">
|
|
581
717
|
<div class="viewer-insights__label">${escapeHtml(label)}</div>
|
|
582
718
|
<div class="viewer-insights__value">${escapeHtml(value)}</div>
|
|
583
719
|
</div>
|
|
584
720
|
`).join("");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function renderInsightRows(items, emptyText = "No signals") {
|
|
724
|
+
if (!items.length) {
|
|
725
|
+
return `<li class="viewer-insights__item">${escapeHtml(emptyText)}</li>`;
|
|
726
|
+
}
|
|
727
|
+
return items.map(([label, value]) => `
|
|
728
|
+
<li class="viewer-insights__item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></li>
|
|
729
|
+
`).join("");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function renderDocRows(items, emptyText = "None", limit = 6) {
|
|
733
|
+
if (!items.length) {
|
|
734
|
+
return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
|
|
735
|
+
}
|
|
736
|
+
const rows = items.map((item, index) => {
|
|
737
|
+
const path = item.relPath || item.path || "";
|
|
738
|
+
const control = path && isSafeLogicsDocPath(path)
|
|
739
|
+
? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(item.id || path)}</button>`
|
|
740
|
+
: `<span class="viewer-insights__doc">${escapeHtml(item.id || path || item.title)}</span>`;
|
|
741
|
+
return `
|
|
742
|
+
<li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>
|
|
743
|
+
${control}
|
|
744
|
+
<span>${escapeHtml(item.indicators?.Status || item.stage || "No status")}</span>
|
|
745
|
+
</li>
|
|
746
|
+
`;
|
|
747
|
+
});
|
|
748
|
+
const hiddenCount = Math.max(0, items.length - limit);
|
|
749
|
+
if (hiddenCount > 0) {
|
|
750
|
+
rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
|
|
751
|
+
}
|
|
752
|
+
return rows.join("");
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function renderPathRows(paths, emptyText = "None", limit = 6) {
|
|
756
|
+
if (!paths.length) {
|
|
757
|
+
return `<li class="viewer-insights__row viewer-insights__row--empty">${escapeHtml(emptyText)}</li>`;
|
|
758
|
+
}
|
|
759
|
+
const rows = paths.map((path, index) => {
|
|
760
|
+
const control = isSafeLogicsDocPath(path)
|
|
761
|
+
? `<button class="viewer-insights__doc" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
|
|
762
|
+
: `<span class="viewer-insights__doc">${escapeHtml(path)}</span>`;
|
|
763
|
+
return `<li class="viewer-insights__row" ${index >= limit ? "hidden data-viewer-hidden-row" : ""}>${control}</li>`;
|
|
764
|
+
});
|
|
765
|
+
const hiddenCount = Math.max(0, paths.length - limit);
|
|
766
|
+
if (hiddenCount > 0) {
|
|
767
|
+
rows.push(`<li class="viewer-insights__row"><button class="viewer-insights__reveal" type="button" data-viewer-reveal>Show ${hiddenCount} more</button></li>`);
|
|
768
|
+
}
|
|
769
|
+
return rows.join("");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function renderActionRows(actions) {
|
|
773
|
+
return actions.map((action) => {
|
|
774
|
+
if (action.filter) {
|
|
775
|
+
return `
|
|
776
|
+
<li class="viewer-insights__row">
|
|
777
|
+
<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>
|
|
778
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
779
|
+
</li>
|
|
780
|
+
`;
|
|
781
|
+
}
|
|
782
|
+
if (action.health) {
|
|
783
|
+
return `
|
|
784
|
+
<li class="viewer-insights__row">
|
|
785
|
+
<button class="viewer-insights__action" type="button" data-viewer-open-health>${escapeHtml(action.label)}</button>
|
|
786
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
787
|
+
</li>
|
|
788
|
+
`;
|
|
789
|
+
}
|
|
790
|
+
if (action.path && isSafeLogicsDocPath(action.path)) {
|
|
791
|
+
return `
|
|
792
|
+
<li class="viewer-insights__row">
|
|
793
|
+
<button class="viewer-insights__action" type="button" data-viewer-doc-path="${escapeHtml(action.path)}">${escapeHtml(action.label)}</button>
|
|
794
|
+
<strong>${escapeHtml(action.value)}</strong>
|
|
795
|
+
</li>
|
|
796
|
+
`;
|
|
797
|
+
}
|
|
798
|
+
return `<li class="viewer-insights__row"><span>${escapeHtml(action.label)}</span><strong>${escapeHtml(action.value)}</strong></li>`;
|
|
799
|
+
}).join("");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function itemLabel(item) {
|
|
803
|
+
return `${item.id || item.relPath || "doc"} - ${item.indicators?.Status || "No status"}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function buildCorpusInsights(lintData = null, auditData = null) {
|
|
807
|
+
const docs = latestItems;
|
|
808
|
+
const itemPaths = new Set(docs.map((item) => item.relPath).filter(Boolean));
|
|
809
|
+
const countsByStage = countBy(docs, (item) => item.stage);
|
|
810
|
+
const closed = docs.filter(isClosed);
|
|
811
|
+
const open = docs.filter((item) => !isClosed(item));
|
|
812
|
+
const blocked = docs.filter((item) => statusValue(item).includes("blocked"));
|
|
813
|
+
const missingStatus = docs.filter(hasMissingOrAmbiguousStatus);
|
|
814
|
+
const recentlyModified = docs.filter((item) => isRecent(item, 7));
|
|
815
|
+
const incompleteChains = docs.filter((item) => ["request", "backlog"].includes(item.stage) && !item.isPromoted && !isClosed(item));
|
|
816
|
+
const unlinked = docs.filter((item) => (item.references || []).length === 0 && (item.usedBy || []).length === 0);
|
|
817
|
+
const brokenRefs = [];
|
|
818
|
+
const relationshipCounts = {};
|
|
819
|
+
docs.forEach((item) => {
|
|
820
|
+
relationshipCounts[item.stage] = (relationshipCounts[item.stage] || 0) + (item.references || []).length + (item.usedBy || []).length;
|
|
821
|
+
(item.references || []).forEach((ref) => {
|
|
822
|
+
if (ref.path && !itemPaths.has(ref.path)) {
|
|
823
|
+
brokenRefs.push(`${item.id} -> ${ref.path}`);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
});
|
|
827
|
+
const mostReferenced = [...docs]
|
|
828
|
+
.sort((left, right) => (right.usedBy || []).length - (left.usedBy || []).length)
|
|
829
|
+
.filter((item) => (item.usedBy || []).length > 0)
|
|
830
|
+
.slice(0, 8);
|
|
831
|
+
const recentRows = [...docs]
|
|
832
|
+
.sort((left, right) => (Date.parse(right.updatedAt || "") || 0) - (Date.parse(left.updatedAt || "") || 0))
|
|
833
|
+
.slice(0, 8);
|
|
834
|
+
const staleActive = open.filter(isStale).slice(0, 8);
|
|
835
|
+
const qualityFindings = lintData && auditData ? collectHealthFindings(lintData, auditData) : [];
|
|
836
|
+
const qualityBySource = countBy(qualityFindings, (finding) => finding.source || finding.code || "finding");
|
|
837
|
+
const qualityByDocType = countBy(qualityFindings, (finding) => {
|
|
838
|
+
const path = String(finding.path || "");
|
|
839
|
+
const matched = docs.find((item) => item.relPath === path);
|
|
840
|
+
return matched?.stage || (path ? "unknown document" : "repository");
|
|
841
|
+
});
|
|
842
|
+
const concentratedIssues = Object.entries(countBy(qualityFindings, (finding) => finding.path || "repository"))
|
|
843
|
+
.sort((left, right) => Number(right[1]) - Number(left[1]))
|
|
844
|
+
.slice(0, 8);
|
|
845
|
+
const actions = [];
|
|
846
|
+
if (blocked.length) {
|
|
847
|
+
actions.push({ label: "Review blocked workflow docs", value: blocked.length, filter: { group: "focus", value: "blocked" } });
|
|
848
|
+
}
|
|
849
|
+
if (incompleteChains.length) {
|
|
850
|
+
actions.push({ label: "Promote or close incomplete workflow chains", value: incompleteChains.length, filter: { group: "focus", value: "needs-promotion" } });
|
|
851
|
+
}
|
|
852
|
+
if (brokenRefs.length) {
|
|
853
|
+
actions.push({ label: "Repair broken references", value: brokenRefs.length, health: true });
|
|
854
|
+
}
|
|
855
|
+
if (qualityFindings.length) {
|
|
856
|
+
actions.push({ label: "Open validation health", value: qualityFindings.length, health: true });
|
|
857
|
+
}
|
|
858
|
+
if (missingStatus.length) {
|
|
859
|
+
actions.push({ label: "Normalize missing or ambiguous statuses", value: missingStatus.length, path: missingStatus[0]?.relPath || "" });
|
|
860
|
+
}
|
|
861
|
+
if (!actions.length) {
|
|
862
|
+
actions.push({ label: "No immediate operator action detected", value: "OK" });
|
|
863
|
+
}
|
|
864
|
+
|
|
585
865
|
const stageRows = Object.entries(countsByStage)
|
|
586
866
|
.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("");
|
|
867
|
+
.map(([stage, count]) => [stage, count]);
|
|
594
868
|
return `
|
|
595
869
|
<div class="viewer-insights">
|
|
596
|
-
<div class="viewer-insights__summary">${
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
870
|
+
<div class="viewer-insights__summary">${renderMetricCards([
|
|
871
|
+
["Docs", docs.length],
|
|
872
|
+
["Open", open.length],
|
|
873
|
+
["Closed", closed.length],
|
|
874
|
+
["Blocked", blocked.length],
|
|
875
|
+
["Missing status", missingStatus.length],
|
|
876
|
+
["Modified 7d", recentlyModified.length]
|
|
877
|
+
])}</div>
|
|
878
|
+
<section class="viewer-insights__section">
|
|
879
|
+
<h2>Overview</h2>
|
|
880
|
+
<ul class="viewer-insights__list">${renderInsightRows(stageRows, "No docs loaded")}</ul>
|
|
881
|
+
</section>
|
|
882
|
+
<section class="viewer-insights__section">
|
|
883
|
+
<h2>Flow health</h2>
|
|
884
|
+
<ul class="viewer-insights__list">${renderInsightRows([
|
|
885
|
+
["Incomplete workflow chains", incompleteChains.length],
|
|
886
|
+
["Promotion gaps", incompleteChains.filter((item) => item.stage === "request" || item.stage === "backlog").length],
|
|
887
|
+
["Orphan or unlinked docs", unlinked.length],
|
|
888
|
+
["Broken reference risks", brokenRefs.length]
|
|
889
|
+
])}</ul>
|
|
890
|
+
<ul class="viewer-insights__rows">${renderDocRows(incompleteChains, "No incomplete chains")}</ul>
|
|
891
|
+
</section>
|
|
892
|
+
<section class="viewer-insights__section">
|
|
893
|
+
<h2>Activity</h2>
|
|
894
|
+
<ul class="viewer-insights__list">${renderInsightRows([
|
|
895
|
+
["Latest changes", recentRows.map(itemLabel).join(", ") || "None"],
|
|
896
|
+
["Stale active docs", staleActive.map(itemLabel).join(", ") || "None"],
|
|
897
|
+
["Recently active docs", recentlyModified.slice(0, 8).map(itemLabel).join(", ") || "None"],
|
|
898
|
+
["Activity classification", `recent ${recentlyModified.length}, stale ${open.filter(isStale).length}, quiet ${Math.max(0, open.length - recentlyModified.length)}`]
|
|
899
|
+
])}</ul>
|
|
900
|
+
<ul class="viewer-insights__rows">${renderDocRows(recentRows, "No recent documents")}</ul>
|
|
901
|
+
</section>
|
|
902
|
+
<section class="viewer-insights__section">
|
|
903
|
+
<h2>Traceability</h2>
|
|
904
|
+
<ul class="viewer-insights__list">${renderInsightRows([
|
|
905
|
+
["Most referenced docs", mostReferenced.map((item) => `${item.id} (${(item.usedBy || []).length})`).join(", ") || "None"],
|
|
906
|
+
["Unlinked docs", unlinked.slice(0, 8).map((item) => item.id).join(", ") || "None"],
|
|
907
|
+
["Broken references", brokenRefs.slice(0, 8).join(", ") || "None"],
|
|
908
|
+
["Relationships by type", Object.entries(relationshipCounts).map(([stage, count]) => `${stage} ${count}`).join(", ") || "None"]
|
|
909
|
+
])}</ul>
|
|
910
|
+
<ul class="viewer-insights__rows">${renderDocRows(unlinked, "No unlinked documents")}${renderPathRows(brokenRefs, "No broken references")}</ul>
|
|
911
|
+
</section>
|
|
603
912
|
<section class="viewer-insights__section">
|
|
604
|
-
<h2>
|
|
605
|
-
<ul class="viewer-insights__list">${
|
|
913
|
+
<h2>Quality signals</h2>
|
|
914
|
+
<ul class="viewer-insights__list">${renderInsightRows([
|
|
915
|
+
["Lint/audit categories", Object.entries(qualityBySource).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
|
|
916
|
+
["Findings by document type", Object.entries(qualityByDocType).map(([key, count]) => `${key} ${count}`).join(", ") || "No findings loaded"],
|
|
917
|
+
["Concentrated issues", concentratedIssues.map(([key, count]) => `${key} ${count}`).join(", ") || "None"]
|
|
918
|
+
])}</ul>
|
|
919
|
+
<ul class="viewer-insights__rows">${renderPathRows(concentratedIssues.map(([key, count]) => `${key} (${count})`), "No concentrated issues")}</ul>
|
|
606
920
|
</section>
|
|
607
921
|
<section class="viewer-insights__section">
|
|
608
|
-
<h2>
|
|
609
|
-
<ul class="viewer-
|
|
922
|
+
<h2>Operator actions</h2>
|
|
923
|
+
<ul class="viewer-insights__rows">${renderActionRows(actions)}</ul>
|
|
610
924
|
</section>
|
|
611
925
|
</div>
|
|
612
926
|
`;
|
|
613
927
|
}
|
|
614
928
|
|
|
615
|
-
function showCorpusInsights() {
|
|
616
|
-
|
|
929
|
+
async function showCorpusInsights() {
|
|
930
|
+
const [lintResponse, auditResponse] = await Promise.all([fetch("/api/lint"), fetch("/api/audit")]);
|
|
931
|
+
const [lintData, auditData] = await Promise.all([lintResponse.json(), auditResponse.json()]);
|
|
932
|
+
setDocument("Corpus insights", buildCorpusInsights(lintData, auditData));
|
|
617
933
|
setMeta("Corpus insights loaded.");
|
|
618
934
|
}
|
|
619
935
|
|
|
@@ -711,9 +1027,9 @@
|
|
|
711
1027
|
const list = findings.length
|
|
712
1028
|
? findings.slice(0, 50).map((finding) => {
|
|
713
1029
|
const path = finding.path || "";
|
|
714
|
-
const pathControl = path
|
|
1030
|
+
const pathControl = path && isSafeLogicsDocPath(path)
|
|
715
1031
|
? `<button class="viewer-health__path" type="button" data-viewer-doc-path="${escapeHtml(path)}">${escapeHtml(path)}</button>`
|
|
716
|
-
:
|
|
1032
|
+
: `<span class="viewer-health__meta">${escapeHtml(path ? `Repository-level or unsafe path: ${path}` : "Repository-level finding")}</span>`;
|
|
717
1033
|
const severity = finding.severity || finding.code || finding.source || "finding";
|
|
718
1034
|
return `
|
|
719
1035
|
<li class="viewer-health__issue">
|
|
@@ -744,6 +1060,76 @@
|
|
|
744
1060
|
setMeta("Health loaded.");
|
|
745
1061
|
}
|
|
746
1062
|
|
|
1063
|
+
function renderGitStatus(payload) {
|
|
1064
|
+
if (!payload || payload.state !== "ok") {
|
|
1065
|
+
return `
|
|
1066
|
+
<div class="viewer-git">
|
|
1067
|
+
<div class="viewer-git__state">${escapeHtml(payload?.message || "Git status is unavailable.")}</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
`;
|
|
1070
|
+
}
|
|
1071
|
+
const counts = payload.counts || {};
|
|
1072
|
+
const summary = [
|
|
1073
|
+
["Branch", payload.branch || "HEAD"],
|
|
1074
|
+
["Tracking", payload.tracking || "None"],
|
|
1075
|
+
["Ahead", payload.ahead || 0],
|
|
1076
|
+
["Behind", payload.behind || 0],
|
|
1077
|
+
["State", payload.clean ? "Clean" : "Dirty"],
|
|
1078
|
+
["Staged", counts.staged || 0],
|
|
1079
|
+
["Modified/deleted", Number(counts.modified || 0) + Number(counts.deleted || 0)],
|
|
1080
|
+
["Untracked", counts.untracked || 0]
|
|
1081
|
+
];
|
|
1082
|
+
const cards = renderMetricCards(summary);
|
|
1083
|
+
const groupLabels = { staged: "Staged", modified: "Modified", deleted: "Deleted", renamed: "Renamed", untracked: "Untracked" };
|
|
1084
|
+
const groups = Object.entries(groupLabels).map(([key, label]) => {
|
|
1085
|
+
const entries = Array.isArray(payload.groups?.[key]) ? payload.groups[key] : [];
|
|
1086
|
+
if (!entries.length) {
|
|
1087
|
+
return "";
|
|
1088
|
+
}
|
|
1089
|
+
return `
|
|
1090
|
+
<section class="viewer-git__section">
|
|
1091
|
+
<h2>${escapeHtml(label)}</h2>
|
|
1092
|
+
<ul class="viewer-git__files">${entries.map((entry) => `
|
|
1093
|
+
<li><code>${escapeHtml(entry.from ? `${entry.from} -> ${entry.path}` : entry.path)}</code></li>
|
|
1094
|
+
`).join("")}</ul>
|
|
1095
|
+
</section>
|
|
1096
|
+
`;
|
|
1097
|
+
}).join("");
|
|
1098
|
+
const clean = payload.clean ? '<p class="viewer-git__state">Working tree clean.</p>' : "";
|
|
1099
|
+
return `
|
|
1100
|
+
<div class="viewer-git">
|
|
1101
|
+
<div class="viewer-git__summary">${cards}</div>
|
|
1102
|
+
${payload.latestCommit ? `<p class="viewer-git__commit">Latest commit: <code>${escapeHtml(payload.latestCommit)}</code></p>` : ""}
|
|
1103
|
+
${clean}
|
|
1104
|
+
${groups}
|
|
1105
|
+
</div>
|
|
1106
|
+
`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function showGitStatus() {
|
|
1110
|
+
setMeta("Checking Git status...");
|
|
1111
|
+
const response = await fetch("/api/git-status");
|
|
1112
|
+
let data = {};
|
|
1113
|
+
try {
|
|
1114
|
+
data = await response.json();
|
|
1115
|
+
} catch {
|
|
1116
|
+
data = {};
|
|
1117
|
+
}
|
|
1118
|
+
if (response.status === 404) {
|
|
1119
|
+
setDocument("Git status", renderGitStatus({
|
|
1120
|
+
state: "unavailable",
|
|
1121
|
+
message: "Git status endpoint unavailable. Restart the local viewer so it loads the current logics-manager backend."
|
|
1122
|
+
}));
|
|
1123
|
+
setMeta("Restart the local viewer to enable Git status.");
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (!response.ok || !data.ok) {
|
|
1127
|
+
throw new Error(data.error || "Unable to load Git status.");
|
|
1128
|
+
}
|
|
1129
|
+
setDocument("Git status", renderGitStatus(data.payload));
|
|
1130
|
+
setMeta("Git status loaded.");
|
|
1131
|
+
}
|
|
1132
|
+
|
|
747
1133
|
window.acquireVsCodeApi = function acquireVsCodeApi() {
|
|
748
1134
|
return {
|
|
749
1135
|
postMessage(message) {
|
|
@@ -786,11 +1172,18 @@
|
|
|
786
1172
|
setControlValue("show-companion-docs", true, "change");
|
|
787
1173
|
setControlValue("hide-empty-columns", true, "change");
|
|
788
1174
|
applyLocalViewerChrome();
|
|
789
|
-
[document.getElementById("viewer-insights")
|
|
1175
|
+
[document.getElementById("viewer-insights")].forEach((button) => {
|
|
790
1176
|
button?.addEventListener("click", () => {
|
|
791
|
-
showCorpusInsights();
|
|
1177
|
+
showCorpusInsights().catch((error) => setMeta(error.message));
|
|
792
1178
|
});
|
|
793
1179
|
});
|
|
1180
|
+
const autoControl = autoRefreshControl();
|
|
1181
|
+
if (autoControl instanceof HTMLInputElement) {
|
|
1182
|
+
autoControl.addEventListener("change", () => {
|
|
1183
|
+
setAutoRefreshEnabled(autoControl.checked);
|
|
1184
|
+
});
|
|
1185
|
+
setAutoRefreshEnabled(autoControl.checked);
|
|
1186
|
+
}
|
|
794
1187
|
document.querySelectorAll('[data-action="refresh"]').forEach((element) => {
|
|
795
1188
|
if (!(element instanceof HTMLElement)) {
|
|
796
1189
|
return;
|
|
@@ -802,6 +1195,12 @@
|
|
|
802
1195
|
document.getElementById("viewer-health")?.addEventListener("click", () => {
|
|
803
1196
|
showHealth().catch((error) => setMeta(error.message));
|
|
804
1197
|
});
|
|
1198
|
+
document.getElementById("viewer-git")?.addEventListener("click", () => {
|
|
1199
|
+
showGitStatus().catch((error) => setMeta(error.message));
|
|
1200
|
+
});
|
|
1201
|
+
activityClearControl()?.addEventListener("click", () => {
|
|
1202
|
+
clearActivityHistory();
|
|
1203
|
+
});
|
|
805
1204
|
document.querySelectorAll("[data-viewer-filter-group]").forEach((element) => {
|
|
806
1205
|
if (element instanceof HTMLSelectElement) {
|
|
807
1206
|
element.addEventListener("change", () => {
|
|
@@ -828,10 +1227,30 @@
|
|
|
828
1227
|
document.addEventListener("click", (event) => {
|
|
829
1228
|
window.setTimeout(() => applyLocalViewerChrome(), 0);
|
|
830
1229
|
const target = event.target instanceof Element ? event.target.closest("[data-viewer-doc-path]") : null;
|
|
831
|
-
|
|
1230
|
+
const healthTarget = event.target instanceof Element ? event.target.closest("[data-viewer-open-health]") : null;
|
|
1231
|
+
const filterTarget = event.target instanceof Element ? event.target.closest("[data-viewer-filter-group][data-viewer-filter-value]") : null;
|
|
1232
|
+
const revealTarget = event.target instanceof Element ? event.target.closest("[data-viewer-reveal]") : null;
|
|
1233
|
+
if (revealTarget instanceof HTMLElement) {
|
|
1234
|
+
const list = revealTarget.closest("ul");
|
|
1235
|
+
list?.querySelectorAll("[data-viewer-hidden-row]").forEach((row) => {
|
|
1236
|
+
if (row instanceof HTMLElement) {
|
|
1237
|
+
row.hidden = false;
|
|
1238
|
+
row.removeAttribute("data-viewer-hidden-row");
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
revealTarget.closest("li")?.remove();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (healthTarget instanceof HTMLElement) {
|
|
1245
|
+
showHealth().catch((error) => setMeta(error.message));
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
if (filterTarget instanceof HTMLElement) {
|
|
1249
|
+
applyViewerFilter(filterTarget.getAttribute("data-viewer-filter-group") || "", filterTarget.getAttribute("data-viewer-filter-value") || "");
|
|
1250
|
+
setMeta("Insight filter applied. Clear filters restores the normal viewer view.");
|
|
832
1251
|
return;
|
|
833
1252
|
}
|
|
834
|
-
const path = target.getAttribute("data-viewer-doc-path");
|
|
1253
|
+
const path = target instanceof HTMLElement ? target.getAttribute("data-viewer-doc-path") : "";
|
|
835
1254
|
if (path) {
|
|
836
1255
|
showDocumentByPath(path).catch((error) => setMeta(error.message));
|
|
837
1256
|
}
|