@lyric_dev/data-loom 0.2.4 → 0.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 +12 -9
- package/dist/index.js +5 -15
- package/dist/mcpServer.js +88 -42
- package/dist/server.js +70 -1
- package/package.json +1 -1
- package/public/app.js +459 -244
- package/public/index.html +51 -18
- package/public/style.css +829 -189
package/public/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
// ── DOM refs ──
|
|
3
4
|
const board = document.getElementById("board");
|
|
4
5
|
const edgesSvg = document.getElementById("edges");
|
|
5
6
|
const doneBand = document.getElementById("doneband");
|
|
@@ -11,42 +12,76 @@ const genEl = document.getElementById("gen");
|
|
|
11
12
|
const conflictsEl = document.getElementById("conflicts");
|
|
12
13
|
const reviewEl = document.getElementById("review");
|
|
13
14
|
const projectSelect = document.getElementById("project-select");
|
|
15
|
+
const themeToggle = document.getElementById("theme-toggle");
|
|
16
|
+
const topoNodes = document.getElementById("topo-nodes");
|
|
17
|
+
const spokesSvg = document.getElementById("spokes");
|
|
14
18
|
|
|
19
|
+
// ── client state ──
|
|
15
20
|
let projects = null;
|
|
16
21
|
let model = null;
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
let
|
|
22
|
+
let mcpModel = null;
|
|
23
|
+
let activeTab = "roadmap";
|
|
24
|
+
let scopeFilter = "all";
|
|
25
|
+
let selectedChange = null; // change name shown in the detail panel
|
|
26
|
+
let selectedServer = null; // server name shown in the detail panel
|
|
27
|
+
let nextUpNames = new Set(); // ready proposals in the earliest ready phase
|
|
28
|
+
let conflictedNames = new Set();
|
|
20
29
|
let doneCollapsed = true;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
const liveOverride = new Map(); // server name -> transient liveness (e.g. "checking")
|
|
31
|
+
|
|
32
|
+
// ── roadmap graph geometry (fixed design canvas, 1180×600) ──
|
|
33
|
+
const G_MX = 150,
|
|
34
|
+
G_W = 1180,
|
|
35
|
+
N_HALF = 105,
|
|
36
|
+
N_TOP = 58,
|
|
37
|
+
V_SPACE = 170;
|
|
38
|
+
|
|
39
|
+
// ── theme ──
|
|
40
|
+
function applyTheme(t) {
|
|
41
|
+
// t: "light" | "dark" (light = blueprint-light default, dark = blueprint)
|
|
42
|
+
if (t === "dark") document.documentElement.dataset.theme = "dark";
|
|
43
|
+
else delete document.documentElement.dataset.theme;
|
|
44
|
+
const light = t !== "dark";
|
|
45
|
+
themeToggle.querySelector(".theme-icon").textContent = light ? "☾" : "☀";
|
|
46
|
+
themeToggle.querySelector(".theme-label").textContent = light ? "dark" : "light";
|
|
47
|
+
themeToggle.title = light ? "Switch to dark" : "Switch to light";
|
|
48
|
+
}
|
|
49
|
+
let theme = localStorage.getItem("dataloom-theme") === "dark" ? "dark" : "light";
|
|
50
|
+
applyTheme(theme);
|
|
51
|
+
themeToggle.addEventListener("click", () => {
|
|
52
|
+
theme = theme === "dark" ? "light" : "dark";
|
|
53
|
+
localStorage.setItem("dataloom-theme", theme);
|
|
54
|
+
applyTheme(theme);
|
|
30
55
|
});
|
|
31
56
|
|
|
32
|
-
//
|
|
33
|
-
|
|
57
|
+
// ── tab switching (active state re-derived from `activeTab`) ──
|
|
58
|
+
function syncTabs() {
|
|
59
|
+
for (const b of document.querySelectorAll(".tab[data-tab]")) b.classList.toggle("active", b.dataset.tab === activeTab);
|
|
60
|
+
for (const v of document.querySelectorAll(".view")) v.classList.toggle("active", v.id === activeTab);
|
|
61
|
+
}
|
|
34
62
|
for (const btn of document.querySelectorAll(".tab[data-tab]")) {
|
|
35
63
|
btn.addEventListener("click", () => {
|
|
36
64
|
activeTab = btn.dataset.tab;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
closeDetail();
|
|
66
|
+
syncTabs();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── scope filter ──
|
|
71
|
+
for (const chip of document.querySelectorAll(".scope-chip[data-scope]")) {
|
|
72
|
+
chip.addEventListener("click", () => {
|
|
73
|
+
scopeFilter = chip.dataset.scope;
|
|
74
|
+
for (const c of document.querySelectorAll(".scope-chip")) c.classList.toggle("active", c === chip);
|
|
75
|
+
renderTopology();
|
|
41
76
|
});
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
//
|
|
79
|
+
// ── project selection ──
|
|
45
80
|
projectSelect.addEventListener("change", async () => {
|
|
46
81
|
const path = projectSelect.value;
|
|
47
82
|
try {
|
|
48
83
|
const res = await fetch(`/api/project/select?path=${encodeURIComponent(path)}`, { method: "POST" });
|
|
49
|
-
if (!res.ok) renderProjects(projects);
|
|
84
|
+
if (!res.ok) renderProjects(projects);
|
|
50
85
|
} catch (e) {
|
|
51
86
|
console.error("project switch failed", e);
|
|
52
87
|
renderProjects(projects);
|
|
@@ -57,11 +92,11 @@ function setProjects(p) {
|
|
|
57
92
|
projects = p;
|
|
58
93
|
renderProjects(p);
|
|
59
94
|
if (!p.current) {
|
|
60
|
-
|
|
61
|
-
board.
|
|
62
|
-
edgesSvg.innerHTML = "";
|
|
95
|
+
clearBoard();
|
|
96
|
+
board.appendChild(el("div", "empty-state", "Select a project above to begin."));
|
|
63
97
|
doneBand.classList.add("hidden");
|
|
64
98
|
conflictsEl.classList.add("hidden");
|
|
99
|
+
reviewEl.classList.add("hidden");
|
|
65
100
|
}
|
|
66
101
|
}
|
|
67
102
|
|
|
@@ -102,13 +137,11 @@ function setConn(live) {
|
|
|
102
137
|
connText.textContent = live ? "live" : "reconnecting…";
|
|
103
138
|
}
|
|
104
139
|
|
|
140
|
+
// ═══════════════ ROADMAP ═══════════════
|
|
141
|
+
|
|
105
142
|
function render(m) {
|
|
106
143
|
model = m;
|
|
107
|
-
|
|
108
|
-
if (detailChange && !m.changes.some((c) => c.name === detailChange)) {
|
|
109
|
-
detail.classList.add("hidden");
|
|
110
|
-
detailChange = null;
|
|
111
|
-
}
|
|
144
|
+
if (selectedChange && !m.changes.some((c) => c.name === selectedChange)) closeDetail();
|
|
112
145
|
genEl.textContent = m.generatedAt ? "· " + new Date(m.generatedAt).toLocaleTimeString() : "";
|
|
113
146
|
conflictedNames = new Set((m.conflicts || []).flatMap((c) => c.changes));
|
|
114
147
|
renderConflicts(m.conflicts || []);
|
|
@@ -119,104 +152,154 @@ function render(m) {
|
|
|
119
152
|
nextUpNames = new Set(ready.filter((c) => c.phase === minPhase).map((c) => c.name));
|
|
120
153
|
renderBoard();
|
|
121
154
|
renderDoneBand();
|
|
122
|
-
// Wait a frame so layout settles before measuring for edges.
|
|
123
|
-
requestAnimationFrame(drawEdges);
|
|
124
155
|
}
|
|
125
156
|
|
|
126
157
|
function renderConflicts(conflicts) {
|
|
158
|
+
conflictsEl.innerHTML = "";
|
|
127
159
|
if (!conflicts.length) {
|
|
128
160
|
conflictsEl.classList.add("hidden");
|
|
129
|
-
conflictsEl.innerHTML = "";
|
|
130
161
|
return;
|
|
131
162
|
}
|
|
132
163
|
conflictsEl.classList.remove("hidden");
|
|
133
|
-
conflictsEl.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
164
|
+
conflictsEl.appendChild(el("span", "conflicts-mark", "⚠"));
|
|
165
|
+
const body = el("div");
|
|
166
|
+
body.appendChild(el("div", "conflicts-head", `${conflicts.length} ordering conflict${conflicts.length > 1 ? "s" : ""}`));
|
|
167
|
+
for (const c of conflicts) body.appendChild(el("div", "conflict-item", c.description));
|
|
168
|
+
conflictsEl.appendChild(body);
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
function renderReview() {
|
|
141
|
-
|
|
172
|
+
reviewEl.innerHTML = "";
|
|
142
173
|
const pending = model.changes.filter((c) => !c.archived && c.dependencyReview === "pending");
|
|
143
174
|
const n = pending.length;
|
|
144
175
|
if (!n) {
|
|
145
176
|
reviewEl.classList.add("hidden");
|
|
146
|
-
reviewEl.innerHTML = "";
|
|
147
177
|
return;
|
|
148
178
|
}
|
|
149
179
|
reviewEl.classList.remove("hidden");
|
|
180
|
+
reviewEl.appendChild(el("span", "review-badge", String(n)));
|
|
181
|
+
const txt = el("span", "review-text");
|
|
150
182
|
const subject = n > 1 ? "proposals need" : "proposal needs";
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
txt.innerHTML = `${n} ${subject} dependency review — ask Claude to <b>weave</b> ${n > 1 ? "them" : "it"} via the data-loom MCP server.`;
|
|
184
|
+
reviewEl.appendChild(txt);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function clearBoard() {
|
|
188
|
+
for (const ch of [...board.children]) if (ch !== edgesSvg) ch.remove();
|
|
189
|
+
edgesSvg.innerHTML = "";
|
|
154
190
|
}
|
|
155
191
|
|
|
156
192
|
function renderBoard() {
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
clearBoard();
|
|
194
|
+
if (!model) return;
|
|
159
195
|
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
const open = model.changes.filter((c) => !c.archived);
|
|
197
|
+
if (!open.length) {
|
|
198
|
+
board.appendChild(el("div", "empty-state", "No active changes yet. Propose one to populate the roadmap."));
|
|
162
199
|
return;
|
|
163
200
|
}
|
|
164
201
|
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
202
|
+
const phaseNums = [...new Set(open.map((c) => c.phase))].sort((a, b) => a - b);
|
|
203
|
+
const span = (G_W - 2 * G_MX) / Math.max(phaseNums.length - 1, 1);
|
|
204
|
+
const gx = (p) => G_MX + phaseNums.indexOf(p) * span;
|
|
205
|
+
|
|
206
|
+
// Grow the canvas vertically so the tallest phase always fits (real projects
|
|
207
|
+
// can stack many cards in one phase). CARD_BAND leaves headroom for the
|
|
208
|
+
// tallest card plus the frame header and top/bottom margins.
|
|
209
|
+
const CARD_BAND = 340;
|
|
210
|
+
const maxN = Math.max(1, ...phaseNums.map((p) => open.filter((c) => c.phase === p).length));
|
|
211
|
+
const canvasH = Math.max(600, (maxN - 1) * V_SPACE + CARD_BAND);
|
|
212
|
+
const cy = canvasH / 2;
|
|
213
|
+
board.style.height = canvasH + "px";
|
|
214
|
+
edgesSvg.setAttribute("viewBox", `0 0 ${G_W} ${canvasH}`);
|
|
215
|
+
|
|
216
|
+
// Phase-band frames (behind everything), sized to the canvas.
|
|
217
|
+
phaseNums.forEach((p, i) => {
|
|
218
|
+
const frame = el("div", "phase-frame");
|
|
219
|
+
frame.style.left = gx(p) - 117 + "px";
|
|
220
|
+
frame.style.height = canvasH - 80 + "px";
|
|
221
|
+
const head = el("div", "phase-frame-head");
|
|
222
|
+
head.appendChild(el("span", "phase-frame-title", "PHASE " + p));
|
|
223
|
+
head.appendChild(el("span", "phase-frame-sub", i === 0 ? "ready" : i === phaseNums.length - 1 ? "final" : "blocked"));
|
|
224
|
+
frame.appendChild(head);
|
|
225
|
+
board.insertBefore(frame, edgesSvg);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Proposal cards (computed positions), each phase's stack centered on the canvas.
|
|
229
|
+
const pos = {};
|
|
230
|
+
phaseNums.forEach((p) => {
|
|
231
|
+
const items = open.filter((c) => c.phase === p);
|
|
232
|
+
items.forEach((c, i) => {
|
|
233
|
+
const x = gx(p);
|
|
234
|
+
const y = cy + (i - (items.length - 1) / 2) * V_SPACE;
|
|
235
|
+
pos[c.name] = { x, y };
|
|
236
|
+
const card = renderCard(c);
|
|
237
|
+
card.style.left = x - N_HALF + "px";
|
|
238
|
+
card.style.top = y - N_TOP + "px";
|
|
239
|
+
board.appendChild(card);
|
|
240
|
+
});
|
|
177
241
|
});
|
|
242
|
+
|
|
243
|
+
drawEdges(open, pos);
|
|
178
244
|
}
|
|
179
245
|
|
|
180
246
|
function renderCard(c) {
|
|
181
|
-
const
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
247
|
+
const card = el("div", "gcard" + (selectedChange === c.name ? " selected" : ""));
|
|
248
|
+
const bar = el("div", "gcard-bar");
|
|
249
|
+
bar.style.background = statusColor(c.status);
|
|
250
|
+
card.appendChild(bar);
|
|
251
|
+
|
|
252
|
+
const body = el("div", "gcard-body");
|
|
253
|
+
body.appendChild(el("div", "gcard-name", c.name));
|
|
254
|
+
|
|
255
|
+
const pills = el("div", "gcard-pills");
|
|
256
|
+
const sp = el("span", "gpill", c.status);
|
|
257
|
+
sp.style.color = statusColor(c.status);
|
|
258
|
+
pills.appendChild(sp);
|
|
259
|
+
if (c.readiness === "ready") pills.appendChild(el("span", "gpill ready", "ready"));
|
|
260
|
+
if (c.dependencyReview === "pending") pills.appendChild(el("span", "gpill review", "review?"));
|
|
261
|
+
body.appendChild(pills);
|
|
262
|
+
|
|
263
|
+
if (c.readiness === "blocked" && c.dependsOn.length) {
|
|
264
|
+
body.appendChild(el("div", "gcard-waiting", "⏳ waiting on " + c.dependsOn.join(", ")));
|
|
194
265
|
}
|
|
195
|
-
if (c.totalTasks > 0) {
|
|
196
|
-
meta.appendChild(el("span", "tasks-mini", `${c.completedTasks}/${c.totalTasks} tasks`));
|
|
197
|
-
}
|
|
198
|
-
card.appendChild(meta);
|
|
199
266
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
267
|
+
const capsParts = [];
|
|
268
|
+
if (c.newCapabilities.length) capsParts.push(`+${c.newCapabilities.length} new`);
|
|
269
|
+
if (c.modifiedCapabilities.length) capsParts.push(`~${c.modifiedCapabilities.length} mod`);
|
|
270
|
+
if (capsParts.length || c.totalTasks > 0) {
|
|
271
|
+
const tasks = c.totalTasks > 0 ? `${c.completedTasks}/${c.totalTasks} tasks` : "";
|
|
272
|
+
const text = [capsParts.join(" "), tasks].filter(Boolean).join(" · ");
|
|
273
|
+
body.appendChild(el("div", "gcard-caps", text));
|
|
206
274
|
}
|
|
275
|
+
card.appendChild(body);
|
|
207
276
|
|
|
208
|
-
|
|
209
|
-
const parts = [];
|
|
210
|
-
if (c.newCapabilities.length) parts.push(`<span class="add">+${c.newCapabilities.length} new</span>`);
|
|
211
|
-
if (c.modifiedCapabilities.length) parts.push(`<span class="mod">~${c.modifiedCapabilities.length} mod</span>`);
|
|
212
|
-
caps.innerHTML = parts.join(" ");
|
|
213
|
-
if (parts.length) card.appendChild(caps);
|
|
277
|
+
if (nextUpNames.has(c.name)) card.appendChild(el("div", "gcard-next", "NEXT UP"));
|
|
214
278
|
|
|
215
|
-
card.addEventListener("click", () =>
|
|
216
|
-
cardEls.set(c.name, card);
|
|
279
|
+
card.addEventListener("click", () => selectChange(c));
|
|
217
280
|
return card;
|
|
218
281
|
}
|
|
219
282
|
|
|
283
|
+
function drawEdges(open, pos) {
|
|
284
|
+
let s =
|
|
285
|
+
'<defs><marker id="dep-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">' +
|
|
286
|
+
'<path d="M1 1L8 5L1 9" fill="none" stroke="var(--edge)" stroke-width="1.6"/></marker></defs>';
|
|
287
|
+
for (const c of open) {
|
|
288
|
+
for (const dep of c.dependsOn) {
|
|
289
|
+
const a = pos[dep];
|
|
290
|
+
const b = pos[c.name];
|
|
291
|
+
if (!a || !b) continue;
|
|
292
|
+
const x1 = a.x + N_HALF,
|
|
293
|
+
y1 = a.y,
|
|
294
|
+
x2 = b.x - N_HALF,
|
|
295
|
+
y2 = b.y,
|
|
296
|
+
mx = (x1 + x2) / 2;
|
|
297
|
+
s += `<path d="M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}" fill="none" stroke="var(--edge)" stroke-width="1.6" stroke-dasharray="5 4" marker-end="url(#dep-arrow)"/>`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
edgesSvg.innerHTML = s;
|
|
301
|
+
}
|
|
302
|
+
|
|
220
303
|
function renderDoneBand() {
|
|
221
304
|
const archived = model.changes.filter((c) => c.archived);
|
|
222
305
|
if (!archived.length) {
|
|
@@ -225,214 +308,346 @@ function renderDoneBand() {
|
|
|
225
308
|
}
|
|
226
309
|
doneBand.classList.remove("hidden");
|
|
227
310
|
doneBand.innerHTML = "";
|
|
228
|
-
const head = el("div", "doneband-head", `${doneCollapsed ? "▸" : "▾"}
|
|
311
|
+
const head = el("div", "doneband-head", `${doneCollapsed ? "▸" : "▾"} archived — ${archived.length} done`);
|
|
229
312
|
head.addEventListener("click", () => {
|
|
230
313
|
doneCollapsed = !doneCollapsed;
|
|
231
314
|
renderDoneBand();
|
|
232
|
-
requestAnimationFrame(drawEdges);
|
|
233
315
|
});
|
|
234
316
|
doneBand.appendChild(head);
|
|
235
|
-
const items = el("div",
|
|
317
|
+
const items = el("div", "doneband-items" + (doneCollapsed ? " collapsed" : ""));
|
|
236
318
|
for (const c of archived) {
|
|
237
319
|
const chip = el("div", "done-chip", c.name);
|
|
238
|
-
chip.addEventListener("click", () =>
|
|
320
|
+
chip.addEventListener("click", () => selectChange(c));
|
|
239
321
|
items.appendChild(chip);
|
|
240
322
|
}
|
|
241
323
|
doneBand.appendChild(items);
|
|
242
324
|
}
|
|
243
325
|
|
|
244
|
-
|
|
245
|
-
edgesSvg.innerHTML =
|
|
246
|
-
'<defs><marker id="dep-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">' +
|
|
247
|
-
'<path d="M2 1L8 5L2 9" fill="none" stroke="var(--edge)" stroke-width="1.5"/></marker></defs>';
|
|
248
|
-
if (!model) return;
|
|
249
|
-
const wrap = board.parentElement.getBoundingClientRect();
|
|
250
|
-
for (const c of model.changes) {
|
|
251
|
-
if (c.archived) continue;
|
|
252
|
-
const toEl = cardEls.get(c.name);
|
|
253
|
-
if (!toEl) continue;
|
|
254
|
-
for (const depName of c.dependsOn) {
|
|
255
|
-
const fromEl = cardEls.get(depName);
|
|
256
|
-
if (!fromEl) continue;
|
|
257
|
-
const f = fromEl.getBoundingClientRect();
|
|
258
|
-
const t = toEl.getBoundingClientRect();
|
|
259
|
-
let d;
|
|
260
|
-
if (t.top >= f.bottom - 4) {
|
|
261
|
-
// dependent sits in a lower band -> vertical connector (bottom -> top)
|
|
262
|
-
const x1 = f.left - wrap.left + f.width / 2;
|
|
263
|
-
const y1 = f.bottom - wrap.top;
|
|
264
|
-
const x2 = t.left - wrap.left + t.width / 2;
|
|
265
|
-
const y2 = t.top - wrap.top;
|
|
266
|
-
const my = (y1 + y2) / 2;
|
|
267
|
-
d = `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
|
|
268
|
-
} else {
|
|
269
|
-
// same band -> horizontal connector (right -> left)
|
|
270
|
-
const x1 = f.right - wrap.left;
|
|
271
|
-
const y1 = f.top - wrap.top + f.height / 2;
|
|
272
|
-
const x2 = t.left - wrap.left;
|
|
273
|
-
const y2 = t.top - wrap.top + t.height / 2;
|
|
274
|
-
const mx = (x1 + x2) / 2;
|
|
275
|
-
d = `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`;
|
|
276
|
-
}
|
|
277
|
-
const p = path(d);
|
|
278
|
-
p.setAttribute("stroke-dasharray", "5 4");
|
|
279
|
-
p.setAttribute("marker-end", "url(#dep-arrow)");
|
|
280
|
-
edgesSvg.appendChild(p);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function showDetail(c) {
|
|
286
|
-
detail.classList.remove("hidden");
|
|
287
|
-
detailChange = c.name;
|
|
288
|
-
detailBody.innerHTML = `
|
|
289
|
-
<h2>${escapeHtml(c.name)}</h2>
|
|
290
|
-
<div class="card-meta">
|
|
291
|
-
<span class="pill s-${c.status}">${c.status}</span>
|
|
292
|
-
${c.archived ? '<span class="pill">archived</span>' : `<span class="pill">Phase ${c.phase}</span>`}
|
|
293
|
-
${c.totalTasks > 0 ? `<span class="tasks-mini">${c.completedTasks}/${c.totalTasks} tasks</span>` : ""}
|
|
294
|
-
</div>
|
|
295
|
-
<h3>New capabilities</h3>${list(c.newCapabilities)}
|
|
296
|
-
<h3>Modified capabilities</h3>${list(c.modifiedCapabilities)}
|
|
297
|
-
<h3>Depends on</h3>${list(c.dependsOn)}
|
|
298
|
-
${c.unsatisfiedDependencies.length ? `<h3>⚠ Unsatisfied</h3>${list(c.unsatisfiedDependencies)}` : ""}
|
|
299
|
-
`;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// --- MCP topology (HOW tab) ---
|
|
303
|
-
let mcpModel = null;
|
|
304
|
-
const topoNodes = document.getElementById("topo-nodes");
|
|
305
|
-
const spokesSvg = document.getElementById("spokes");
|
|
306
|
-
let hubEl = null;
|
|
307
|
-
let serverEls = new Map();
|
|
326
|
+
// ═══════════════ MCP TOPOLOGY (circuit board) ═══════════════
|
|
308
327
|
|
|
309
328
|
function setMcp(m) {
|
|
310
329
|
mcpModel = m;
|
|
330
|
+
if (selectedServer && !m.servers.some((s) => s.name === selectedServer)) closeDetail();
|
|
311
331
|
renderTopology();
|
|
332
|
+
if (selectedServer) renderServerDetail();
|
|
312
333
|
}
|
|
313
334
|
|
|
314
335
|
function updateMcpServer(server) {
|
|
315
336
|
if (!mcpModel) return;
|
|
337
|
+
liveOverride.delete(server.name); // real result arrived — drop transient state
|
|
316
338
|
const i = mcpModel.servers.findIndex((s) => s.name === server.name);
|
|
317
339
|
if (i >= 0) mcpModel.servers[i] = server;
|
|
318
340
|
else mcpModel.servers.push(server);
|
|
319
341
|
renderTopology();
|
|
342
|
+
if (selectedServer === server.name) renderServerDetail();
|
|
320
343
|
}
|
|
321
344
|
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
345
|
+
function liveOf(s) {
|
|
346
|
+
return liveOverride.get(s.name) || s.liveness;
|
|
347
|
+
}
|
|
348
|
+
function visibleServers() {
|
|
349
|
+
if (!mcpModel) return [];
|
|
350
|
+
return scopeFilter === "all" ? mcpModel.servers : mcpModel.servers.filter((s) => s.scope === scopeFilter);
|
|
351
|
+
}
|
|
326
352
|
|
|
327
|
-
|
|
328
|
-
topoNodes.
|
|
353
|
+
function clearTopo() {
|
|
354
|
+
for (const ch of [...topoNodes.children]) if (ch !== spokesSvg) ch.remove();
|
|
355
|
+
spokesSvg.innerHTML = "";
|
|
356
|
+
}
|
|
329
357
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
358
|
+
function renderTopology() {
|
|
359
|
+
if (!mcpModel) return;
|
|
360
|
+
clearTopo();
|
|
361
|
+
|
|
362
|
+
// MCU chip (hub).
|
|
363
|
+
const mcu = el("div", "mcu");
|
|
364
|
+
mcu.appendChild(el("div", "mcu-name", mcpModel.hub || "Claude Code"));
|
|
365
|
+
mcu.appendChild(el("div", "mcu-sub", "MCU · hub"));
|
|
366
|
+
topoNodes.appendChild(mcu);
|
|
367
|
+
|
|
368
|
+
const vis = visibleServers();
|
|
369
|
+
if (!vis.length) {
|
|
370
|
+
topoNodes.appendChild(
|
|
371
|
+
el("div", "topo-empty", mcpModel.servers.length ? "No servers match this scope." : "No MCP servers found in your Claude Code config.")
|
|
372
|
+
);
|
|
373
|
+
return;
|
|
333
374
|
}
|
|
334
375
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const cx = rect.width / 2;
|
|
357
|
-
const cy = rect.height / 2;
|
|
358
|
-
place(hubEl, cx, cy);
|
|
359
|
-
const n = mcpModel.servers.length;
|
|
360
|
-
const R = Math.max(140, Math.min(rect.width, rect.height) / 2 - 130);
|
|
361
|
-
mcpModel.servers.forEach((s, i) => {
|
|
362
|
-
const node = serverEls.get(s.name);
|
|
363
|
-
if (!node) return;
|
|
364
|
-
const ang = (i / Math.max(n, 1)) * Math.PI * 2 - Math.PI / 2;
|
|
365
|
-
place(node, cx + Math.cos(ang) * R, cy + Math.sin(ang) * R);
|
|
376
|
+
const PCB_CX = 600,
|
|
377
|
+
PCB_CY = 320,
|
|
378
|
+
CHW = 152;
|
|
379
|
+
const half = Math.ceil(vis.length / 2);
|
|
380
|
+
const leftBank = vis.slice(0, half);
|
|
381
|
+
const rightBank = vis.slice(half);
|
|
382
|
+
const pinSp = (n) => Math.min(26, 120 / Math.max(n, 1));
|
|
383
|
+
const yOf = (n, i, top, bot) => (n <= 1 ? (top + bot) / 2 : top + i * ((bot - top) / (n - 1)));
|
|
384
|
+
const lineFor = (sc) => (sc === "global" ? "var(--line-a)" : "var(--line-b)");
|
|
385
|
+
const liveUp = (s) => ["available", "already-running"].includes(liveOf(s));
|
|
386
|
+
|
|
387
|
+
const traces = [];
|
|
388
|
+
|
|
389
|
+
leftBank.forEach((s, i) => {
|
|
390
|
+
const yc = yOf(leftBank.length, i, 72, 568),
|
|
391
|
+
padX = 346;
|
|
392
|
+
const pinY = PCB_CY + (i - (leftBank.length - 1) / 2) * pinSp(leftBank.length),
|
|
393
|
+
sx = PCB_CX - CHW / 2,
|
|
394
|
+
midX = 452 - i * 22;
|
|
395
|
+
placeComp(s, 150, yc - 28);
|
|
396
|
+
traces.push({ d: `M ${sx} ${pinY} H ${midX} V ${yc} H ${padX}`, color: lineFor(s.scope), dash: s.scope === "project", flow: liveUp(s), px: sx, py: pinY, qx: padX, qy: yc });
|
|
366
397
|
});
|
|
367
|
-
|
|
398
|
+
rightBank.forEach((s, i) => {
|
|
399
|
+
const yc = yOf(rightBank.length, i, 72, 568),
|
|
400
|
+
padX = 854;
|
|
401
|
+
const pinY = PCB_CY + (i - (rightBank.length - 1) / 2) * pinSp(rightBank.length),
|
|
402
|
+
ex = PCB_CX + CHW / 2,
|
|
403
|
+
midX = 748 + i * 22;
|
|
404
|
+
placeComp(s, 854, yc - 28);
|
|
405
|
+
traces.push({ d: `M ${ex} ${pinY} H ${midX} V ${yc} H ${padX}`, color: lineFor(s.scope), dash: s.scope === "project", flow: liveUp(s), px: ex, py: pinY, qx: padX, qy: yc });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
drawTraces(traces);
|
|
368
409
|
}
|
|
369
410
|
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
411
|
+
function placeComp(s, left, top) {
|
|
412
|
+
const comp = el("div", "pcb-comp" + (selectedServer === s.name ? " selected" : ""));
|
|
413
|
+
comp.style.left = left + "px";
|
|
414
|
+
comp.style.top = top + "px";
|
|
415
|
+
|
|
416
|
+
const topRow = el("div", "pcb-comp-top");
|
|
417
|
+
const led = el("span", "pcb-led");
|
|
418
|
+
const color = livenessColor(liveOf(s));
|
|
419
|
+
led.style.background = color;
|
|
420
|
+
led.style.boxShadow = "0 0 6px " + color;
|
|
421
|
+
topRow.appendChild(led);
|
|
422
|
+
topRow.appendChild(el("span", "pcb-comp-name", s.name));
|
|
423
|
+
comp.appendChild(topRow);
|
|
424
|
+
comp.appendChild(el("div", "pcb-comp-sub", `${s.transport} · ${s.scope} · ${livenessLabel(liveOf(s))}`));
|
|
425
|
+
|
|
426
|
+
comp.addEventListener("click", () => selectServer(s));
|
|
427
|
+
topoNodes.appendChild(comp);
|
|
373
428
|
}
|
|
374
429
|
|
|
375
|
-
function
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const server = mcpModel.servers.find((s) => s.name === name);
|
|
387
|
-
const p = path(`M ${hx} ${hy} L ${x} ${y}`);
|
|
388
|
-
if (server && server.scope === "project") p.setAttribute("stroke-dasharray", "5 4");
|
|
389
|
-
spokesSvg.appendChild(p);
|
|
430
|
+
function drawTraces(traces) {
|
|
431
|
+
let s = "";
|
|
432
|
+
// base traces + solder pads
|
|
433
|
+
for (const t of traces) {
|
|
434
|
+
s += `<path d="${t.d}" fill="none" stroke="${t.color}" stroke-width="2"${t.dash ? ' stroke-dasharray="5 4"' : ""} opacity="0.45"/>`;
|
|
435
|
+
s += `<circle cx="${t.px}" cy="${t.py}" r="3.5" fill="${t.color}"/>`;
|
|
436
|
+
s += `<circle cx="${t.qx}" cy="${t.qy}" r="3.5" fill="${t.color}"/>`;
|
|
437
|
+
}
|
|
438
|
+
// live traffic pulse on reachable links
|
|
439
|
+
for (const t of traces) {
|
|
440
|
+
if (t.flow) s += `<path d="${t.d}" fill="none" stroke="${t.color}" stroke-width="2.4" stroke-dasharray="2 13" style="animation:lm-dash 1s linear infinite"/>`;
|
|
390
441
|
}
|
|
442
|
+
spokesSvg.innerHTML = s;
|
|
391
443
|
}
|
|
392
444
|
|
|
393
|
-
async function triggerCheck(name
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (
|
|
445
|
+
async function triggerCheck(name) {
|
|
446
|
+
liveOverride.set(name, "checking");
|
|
447
|
+
renderTopology();
|
|
448
|
+
if (selectedServer === name) renderServerDetail();
|
|
397
449
|
try {
|
|
398
450
|
await fetch(`/api/mcp/check?name=${encodeURIComponent(name)}`, { method: "POST" });
|
|
399
451
|
// result arrives via the ws 'mcpServer' push -> updateMcpServer re-renders
|
|
400
452
|
} catch (e) {
|
|
401
453
|
console.error("check failed", e);
|
|
454
|
+
liveOverride.delete(name);
|
|
455
|
+
renderTopology();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ═══════════════ DETAIL PANELS ═══════════════
|
|
460
|
+
|
|
461
|
+
function selectChange(c) {
|
|
462
|
+
if (selectedChange === c.name) {
|
|
463
|
+
closeDetail();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
selectedChange = c.name;
|
|
467
|
+
selectedServer = null;
|
|
468
|
+
renderChangeDetail(c);
|
|
469
|
+
renderBoard();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function selectServer(s) {
|
|
473
|
+
if (selectedServer === s.name) {
|
|
474
|
+
closeDetail();
|
|
475
|
+
return;
|
|
402
476
|
}
|
|
477
|
+
selectedServer = s.name;
|
|
478
|
+
selectedChange = null;
|
|
479
|
+
renderServerDetail();
|
|
480
|
+
renderTopology();
|
|
403
481
|
}
|
|
404
482
|
|
|
405
|
-
function
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
"already-running": "running",
|
|
414
|
-
}[s] || s;
|
|
483
|
+
function closeDetail() {
|
|
484
|
+
detail.classList.add("hidden");
|
|
485
|
+
const reChange = selectedChange != null;
|
|
486
|
+
const reServer = selectedServer != null;
|
|
487
|
+
selectedChange = null;
|
|
488
|
+
selectedServer = null;
|
|
489
|
+
if (reChange && model) renderBoard();
|
|
490
|
+
if (reServer && mcpModel) renderTopology();
|
|
415
491
|
}
|
|
416
492
|
|
|
417
|
-
|
|
418
|
-
|
|
493
|
+
function openPanel(server) {
|
|
494
|
+
detail.classList.remove("hidden");
|
|
495
|
+
detail.classList.toggle("server", !!server);
|
|
496
|
+
// re-trigger slide-in animation
|
|
497
|
+
detail.style.animation = "none";
|
|
498
|
+
void detail.offsetWidth;
|
|
499
|
+
detail.style.animation = "";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function detailHeader(name) {
|
|
503
|
+
const head = el("div", "detail-head");
|
|
504
|
+
head.appendChild(el("div", "detail-name", name));
|
|
505
|
+
const close = el("button", "detail-close", "×");
|
|
506
|
+
close.addEventListener("click", closeDetail);
|
|
507
|
+
head.appendChild(close);
|
|
508
|
+
return head;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderChangeDetail(c) {
|
|
512
|
+
openPanel(false);
|
|
513
|
+
detailBody.innerHTML = "";
|
|
514
|
+
const inner = el("div", "detail-inner");
|
|
515
|
+
inner.appendChild(detailHeader(c.name));
|
|
516
|
+
|
|
517
|
+
const pills = el("div", "detail-pills");
|
|
518
|
+
const sp = el("span", "detail-pill", c.status);
|
|
519
|
+
sp.style.color = statusColor(c.status);
|
|
520
|
+
pills.appendChild(sp);
|
|
521
|
+
pills.appendChild(el("span", "detail-pill", c.archived ? "archived" : "phase " + c.phase));
|
|
522
|
+
const rp = el("span", "detail-pill", c.readiness);
|
|
523
|
+
rp.style.color = "var(--accent)";
|
|
524
|
+
pills.appendChild(rp);
|
|
525
|
+
inner.appendChild(pills);
|
|
526
|
+
|
|
527
|
+
if (c.totalTasks > 0) {
|
|
528
|
+
const pct = Math.round((c.completedTasks / c.totalTasks) * 100);
|
|
529
|
+
const tasks = el("div", "detail-tasks");
|
|
530
|
+
const row = el("div", "detail-tasks-row");
|
|
531
|
+
row.appendChild(el("span", null, "tasks"));
|
|
532
|
+
row.appendChild(el("span", null, `${c.completedTasks}/${c.totalTasks} · ${pct}%`));
|
|
533
|
+
tasks.appendChild(row);
|
|
534
|
+
const track = el("div", "detail-tasks-track");
|
|
535
|
+
const fill = el("div", "detail-tasks-fill");
|
|
536
|
+
fill.style.width = pct + "%";
|
|
537
|
+
track.appendChild(fill);
|
|
538
|
+
tasks.appendChild(track);
|
|
539
|
+
inner.appendChild(tasks);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
inner.appendChild(el("div", "detail-section first", "Depends on"));
|
|
543
|
+
if (c.dependsOn.length) inner.appendChild(itemList(c.dependsOn));
|
|
544
|
+
else inner.appendChild(el("div", "detail-none", "— independent —"));
|
|
545
|
+
|
|
546
|
+
inner.appendChild(el("div", "detail-section", "New capabilities"));
|
|
547
|
+
if (c.newCapabilities.length) inner.appendChild(itemList(c.newCapabilities, "add", "+ "));
|
|
548
|
+
else inner.appendChild(el("div", "detail-none", "—"));
|
|
549
|
+
|
|
550
|
+
if (c.modifiedCapabilities.length) {
|
|
551
|
+
inner.appendChild(el("div", "detail-section", "Modified capabilities"));
|
|
552
|
+
inner.appendChild(itemList(c.modifiedCapabilities, "mod", "~ "));
|
|
553
|
+
}
|
|
554
|
+
if (c.unsatisfiedDependencies.length) {
|
|
555
|
+
inner.appendChild(el("div", "detail-section warn", "⚠ Unsatisfied"));
|
|
556
|
+
inner.appendChild(itemList(c.unsatisfiedDependencies, "unsat"));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
detailBody.appendChild(inner);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function renderServerDetail() {
|
|
563
|
+
const s = mcpModel && mcpModel.servers.find((x) => x.name === selectedServer);
|
|
564
|
+
if (!s) return;
|
|
565
|
+
openPanel(true);
|
|
566
|
+
detailBody.innerHTML = "";
|
|
567
|
+
const inner = el("div", "detail-inner");
|
|
568
|
+
inner.appendChild(detailHeader(s.name));
|
|
569
|
+
|
|
570
|
+
const lv = liveOf(s);
|
|
571
|
+
const live = el("div", "detail-live");
|
|
572
|
+
const dot = el("span", "detail-live-dot");
|
|
573
|
+
const color = livenessColor(lv);
|
|
574
|
+
dot.style.background = color;
|
|
575
|
+
dot.style.boxShadow = "0 0 8px " + color;
|
|
576
|
+
live.appendChild(dot);
|
|
577
|
+
live.appendChild(el("span", "detail-live-label", livenessLabel(lv)));
|
|
578
|
+
inner.appendChild(live);
|
|
579
|
+
|
|
580
|
+
const rows = el("div", "detail-rows");
|
|
581
|
+
rows.appendChild(detailRow("transport", s.transport));
|
|
582
|
+
rows.appendChild(detailRow("scope", s.scope));
|
|
583
|
+
rows.appendChild(detailRow("source", s.detail || s.source || "—"));
|
|
584
|
+
inner.appendChild(rows);
|
|
585
|
+
|
|
586
|
+
const btn = el("button", "detail-check", "check availability");
|
|
587
|
+
btn.addEventListener("click", () => triggerCheck(s.name));
|
|
588
|
+
inner.appendChild(btn);
|
|
589
|
+
|
|
590
|
+
inner.appendChild(
|
|
591
|
+
el("p", "detail-note", "Passive probe only — DataLoom never starts a server. It reports what's reachable so you know what to launch yourself.")
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
detailBody.appendChild(inner);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function detailRow(k, v) {
|
|
598
|
+
const row = el("div", "detail-row");
|
|
599
|
+
row.appendChild(el("span", "k", k));
|
|
600
|
+
row.appendChild(el("span", "v", v));
|
|
601
|
+
return row;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function itemList(items, cls, prefix) {
|
|
605
|
+
const list = el("div", "detail-list");
|
|
606
|
+
for (const it of items) list.appendChild(el("span", "detail-item" + (cls ? " " + cls : ""), (prefix || "") + it));
|
|
607
|
+
return list;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ═══════════════ helpers ═══════════════
|
|
611
|
+
|
|
612
|
+
function statusColor(status) {
|
|
613
|
+
return status === "in-progress" ? "var(--c-progress)" : status === "done" ? "var(--c-done)" : "var(--c-draft)";
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function livenessColor(l) {
|
|
617
|
+
return (
|
|
618
|
+
{
|
|
619
|
+
available: "var(--c-done)",
|
|
620
|
+
"already-running": "var(--c-done)",
|
|
621
|
+
"needs-auth": "var(--c-progress)",
|
|
622
|
+
unreachable: "var(--c-warn)",
|
|
623
|
+
"on-demand": "var(--accent)",
|
|
624
|
+
checking: "var(--c-progress)",
|
|
625
|
+
unknown: "var(--muted)",
|
|
626
|
+
}[l] || "var(--muted)"
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function livenessLabel(l) {
|
|
631
|
+
return (
|
|
632
|
+
{
|
|
633
|
+
unknown: "unknown",
|
|
634
|
+
checking: "checking…",
|
|
635
|
+
available: "available",
|
|
636
|
+
"needs-auth": "needs auth",
|
|
637
|
+
unreachable: "unreachable",
|
|
638
|
+
"on-demand": "on-demand",
|
|
639
|
+
"already-running": "running",
|
|
640
|
+
}[l] || l
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function el(tag, cls, text) {
|
|
419
645
|
const e = document.createElement(tag);
|
|
420
646
|
if (cls) e.className = cls;
|
|
421
|
-
if (
|
|
647
|
+
if (text != null) e.textContent = text;
|
|
422
648
|
return e;
|
|
423
649
|
}
|
|
424
|
-
|
|
425
|
-
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
426
|
-
p.setAttribute("d", d);
|
|
427
|
-
p.setAttribute("fill", "none");
|
|
428
|
-
p.setAttribute("stroke", "var(--edge)");
|
|
429
|
-
p.setAttribute("stroke-width", "1.6");
|
|
430
|
-
return p;
|
|
431
|
-
}
|
|
432
|
-
function list(items) {
|
|
433
|
-
if (!items || !items.length) return '<div class="empty">—</div>';
|
|
434
|
-
return "<ul>" + items.map((i) => `<li><code>${escapeHtml(i)}</code></li>`).join("") + "</ul>";
|
|
435
|
-
}
|
|
650
|
+
|
|
436
651
|
function escapeHtml(s) {
|
|
437
652
|
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
438
653
|
}
|