@tenonhq/dovetail-dashboard 0.0.13 → 0.0.14
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/package.json +3 -2
- package/public/app.js +47 -1
- package/public/claude-plans.css +643 -0
- package/public/claude-plans.html +61 -0
- package/public/claude-plans.js +354 -0
- package/public/index.html +1 -0
- package/public/styles.css +26 -0
- package/server.js +259 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/* /claude-plans page logic.
|
|
2
|
+
* - Fetches initial state from REST.
|
|
3
|
+
* - Subscribes to /api/claude-plans/stream (SSE) for live updates.
|
|
4
|
+
* - Renders markdown via marked + DOMPurify, Mermaid via mermaid.run().
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
(function () {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
if (window.mermaid && typeof window.mermaid.initialize === "function") {
|
|
11
|
+
window.mermaid.initialize({ startOnLoad: false, theme: "default", securityLevel: "strict" });
|
|
12
|
+
}
|
|
13
|
+
if (window.marked && typeof window.marked.setOptions === "function") {
|
|
14
|
+
window.marked.setOptions({ breaks: true, gfm: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
var state = {
|
|
18
|
+
plans: new Map(), // slug -> plan
|
|
19
|
+
artifacts: new Map(), // slug -> Map<artifactSlug, artifact>
|
|
20
|
+
selectedSlug: null,
|
|
21
|
+
activeTab: "plan"
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
var els = {
|
|
25
|
+
storage: document.getElementById("cp-storage"),
|
|
26
|
+
list: document.getElementById("cp-list"),
|
|
27
|
+
count: document.getElementById("cp-count"),
|
|
28
|
+
railEmpty: document.getElementById("cp-rail-empty"),
|
|
29
|
+
detailEmpty: document.getElementById("cp-detail-empty"),
|
|
30
|
+
detailBody: document.getElementById("cp-detail-body"),
|
|
31
|
+
detailTitle: document.getElementById("cp-detail-title"),
|
|
32
|
+
detailStatus: document.getElementById("cp-detail-status"),
|
|
33
|
+
detailStamp: document.getElementById("cp-detail-stamp"),
|
|
34
|
+
artifactCount: document.getElementById("cp-artifact-count"),
|
|
35
|
+
planPanel: document.getElementById("cp-tab-plan"),
|
|
36
|
+
artifactsPanel: document.getElementById("cp-tab-artifacts"),
|
|
37
|
+
tabs: document.querySelectorAll(".cp-tab")
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function sortedPlans() {
|
|
41
|
+
return Array.from(state.plans.values()).sort(function (a, b) {
|
|
42
|
+
return (b.updated_at || "").localeCompare(a.updated_at || "");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sortedArtifacts(slug) {
|
|
47
|
+
var map = state.artifacts.get(slug);
|
|
48
|
+
if (!map) return [];
|
|
49
|
+
return Array.from(map.values()).sort(function (a, b) {
|
|
50
|
+
return (a.created_at || "").localeCompare(b.created_at || "");
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fmtTime(iso) {
|
|
55
|
+
if (!iso) return "";
|
|
56
|
+
var d = new Date(iso);
|
|
57
|
+
if (isNaN(d.getTime())) return iso;
|
|
58
|
+
return d.toLocaleString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function showError(msg) {
|
|
62
|
+
var el = document.createElement("div");
|
|
63
|
+
el.style.cssText = "position:fixed;bottom:16px;right:16px;background:var(--danger);color:#fff;padding:8px 14px;border-radius:4px;font-size:13px;z-index:9999";
|
|
64
|
+
el.textContent = msg;
|
|
65
|
+
document.body.appendChild(el);
|
|
66
|
+
setTimeout(function () { el.remove(); }, 4000);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderMarkdown(md, target) {
|
|
70
|
+
if (!window.marked || !window.DOMPurify) {
|
|
71
|
+
target.textContent = md;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
var html = window.marked.parse(md || "");
|
|
75
|
+
target.innerHTML = window.DOMPurify.sanitize(html);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderMermaid(source, target) {
|
|
79
|
+
target.classList.add("cp-mermaid");
|
|
80
|
+
target.textContent = "";
|
|
81
|
+
if (!window.mermaid || typeof window.mermaid.render !== "function") {
|
|
82
|
+
target.textContent = source;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
var id = "mmd-" + Math.random().toString(36).slice(2, 10);
|
|
86
|
+
try {
|
|
87
|
+
window.mermaid.render(id, source).then(
|
|
88
|
+
function (out) { target.innerHTML = out.svg; },
|
|
89
|
+
function (err) {
|
|
90
|
+
target.classList.remove("cp-mermaid");
|
|
91
|
+
target.classList.add("cp-mermaid-error");
|
|
92
|
+
target.textContent = "mermaid error: " + (err && err.message ? err.message : String(err));
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
target.classList.remove("cp-mermaid");
|
|
97
|
+
target.classList.add("cp-mermaid-error");
|
|
98
|
+
target.textContent = "mermaid error: " + err.message;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderRail() {
|
|
103
|
+
var plans = sortedPlans();
|
|
104
|
+
els.count.textContent = String(plans.length);
|
|
105
|
+
if (plans.length === 0) {
|
|
106
|
+
els.railEmpty.style.display = "block";
|
|
107
|
+
els.list.innerHTML = "";
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
els.railEmpty.style.display = "none";
|
|
111
|
+
els.list.innerHTML = "";
|
|
112
|
+
plans.forEach(function (plan) {
|
|
113
|
+
var artifactCount = (state.artifacts.get(plan.slug) || new Map()).size;
|
|
114
|
+
var li = document.createElement("li");
|
|
115
|
+
li.className = "cp-list-item" + (plan.slug === state.selectedSlug ? " active" : "");
|
|
116
|
+
li.tabIndex = 0;
|
|
117
|
+
li.dataset.slug = plan.slug;
|
|
118
|
+
li.innerHTML =
|
|
119
|
+
'<div class="cp-list-row">' +
|
|
120
|
+
' <span class="cp-list-title"></span>' +
|
|
121
|
+
' <span class="cp-status-pill cp-status-' + plan.status + '">' + plan.status + '</span>' +
|
|
122
|
+
'</div>' +
|
|
123
|
+
'<div class="cp-list-meta">' +
|
|
124
|
+
' <span class="cp-list-meta-slug"></span>' +
|
|
125
|
+
' <span class="cp-artifact-badge">' + artifactCount + ' artifact' + (artifactCount === 1 ? '' : 's') + '</span>' +
|
|
126
|
+
'</div>';
|
|
127
|
+
li.querySelector(".cp-list-title").textContent = plan.title;
|
|
128
|
+
li.querySelector(".cp-list-meta-slug").textContent = plan.slug;
|
|
129
|
+
li.addEventListener("click", function () { selectPlan(plan.slug); });
|
|
130
|
+
li.addEventListener("keydown", function (e) {
|
|
131
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectPlan(plan.slug); }
|
|
132
|
+
});
|
|
133
|
+
var delBtn = document.createElement("button");
|
|
134
|
+
delBtn.className = "cp-list-delete";
|
|
135
|
+
delBtn.title = "Delete plan";
|
|
136
|
+
delBtn.textContent = "×";
|
|
137
|
+
delBtn.addEventListener("click", function (e) {
|
|
138
|
+
e.stopPropagation();
|
|
139
|
+
if (!delBtn.dataset.confirm) {
|
|
140
|
+
delBtn.dataset.confirm = "1";
|
|
141
|
+
delBtn.textContent = "?";
|
|
142
|
+
setTimeout(function () {
|
|
143
|
+
delBtn.textContent = "×";
|
|
144
|
+
delete delBtn.dataset.confirm;
|
|
145
|
+
}, 2000);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
fetch("/api/claude-plans/" + encodeURIComponent(plan.slug), { method: "DELETE" })
|
|
149
|
+
.then(function (r) {
|
|
150
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
151
|
+
removePlan(plan.slug);
|
|
152
|
+
})
|
|
153
|
+
.catch(function () {
|
|
154
|
+
delBtn.textContent = "×";
|
|
155
|
+
delete delBtn.dataset.confirm;
|
|
156
|
+
showError("Failed to delete plan. Try again.");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
li.querySelector(".cp-list-row").appendChild(delBtn);
|
|
160
|
+
els.list.appendChild(li);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function renderDetail() {
|
|
165
|
+
if (!state.selectedSlug || !state.plans.has(state.selectedSlug)) {
|
|
166
|
+
els.detailEmpty.style.display = "block";
|
|
167
|
+
els.detailBody.hidden = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
var plan = state.plans.get(state.selectedSlug);
|
|
171
|
+
var artifacts = sortedArtifacts(state.selectedSlug);
|
|
172
|
+
|
|
173
|
+
els.detailEmpty.style.display = "none";
|
|
174
|
+
els.detailBody.hidden = false;
|
|
175
|
+
els.detailTitle.textContent = plan.title;
|
|
176
|
+
els.detailStatus.textContent = plan.status;
|
|
177
|
+
els.detailStatus.className = "cp-status-pill cp-status-" + plan.status;
|
|
178
|
+
els.detailStamp.textContent = "updated " + fmtTime(plan.updated_at);
|
|
179
|
+
els.artifactCount.textContent = String(artifacts.length);
|
|
180
|
+
|
|
181
|
+
var existingPrBadge = document.getElementById("cp-pr-badge");
|
|
182
|
+
if (existingPrBadge) existingPrBadge.remove();
|
|
183
|
+
if (plan.pr_url) {
|
|
184
|
+
var prBadge = document.createElement("a");
|
|
185
|
+
prBadge.id = "cp-pr-badge";
|
|
186
|
+
prBadge.className = "cp-pr-badge";
|
|
187
|
+
prBadge.href = plan.pr_url;
|
|
188
|
+
prBadge.target = "_blank";
|
|
189
|
+
prBadge.rel = "noopener noreferrer";
|
|
190
|
+
prBadge.textContent = plan.pr_title
|
|
191
|
+
? "PR #" + plan.pr_number + " — " + plan.pr_title
|
|
192
|
+
: "PR #" + (plan.pr_number || "");
|
|
193
|
+
els.detailStamp.insertAdjacentElement("afterend", prBadge);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (plan.content_html) {
|
|
197
|
+
els.planPanel.innerHTML = window.DOMPurify
|
|
198
|
+
? window.DOMPurify.sanitize(plan.content_html)
|
|
199
|
+
: plan.content_html;
|
|
200
|
+
} else {
|
|
201
|
+
renderMarkdown(plan.content_md, els.planPanel);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
els.artifactsPanel.innerHTML = "";
|
|
205
|
+
if (artifacts.length === 0) {
|
|
206
|
+
var empty = document.createElement("div");
|
|
207
|
+
empty.className = "cp-detail-empty";
|
|
208
|
+
empty.style.padding = "40px 0";
|
|
209
|
+
empty.textContent = "No artifacts yet. Call push_artifact or push_diagram from Claude.";
|
|
210
|
+
els.artifactsPanel.appendChild(empty);
|
|
211
|
+
} else {
|
|
212
|
+
artifacts.forEach(function (artifact) {
|
|
213
|
+
var card = document.createElement("div");
|
|
214
|
+
card.className = "cp-artifact-card";
|
|
215
|
+
var head = document.createElement("div");
|
|
216
|
+
head.className = "cp-artifact-head";
|
|
217
|
+
var title = document.createElement("span");
|
|
218
|
+
title.className = "cp-artifact-title";
|
|
219
|
+
title.textContent = artifact.title;
|
|
220
|
+
var kind = document.createElement("span");
|
|
221
|
+
kind.className = "cp-kind-pill";
|
|
222
|
+
kind.textContent = artifact.kind;
|
|
223
|
+
head.appendChild(title);
|
|
224
|
+
head.appendChild(kind);
|
|
225
|
+
card.appendChild(head);
|
|
226
|
+
|
|
227
|
+
var body = document.createElement("div");
|
|
228
|
+
if (artifact.kind === "mermaid") renderMermaid(artifact.content, body);
|
|
229
|
+
else renderMarkdown(artifact.content, body);
|
|
230
|
+
card.appendChild(body);
|
|
231
|
+
els.artifactsPanel.appendChild(card);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function setActiveTab(tab) {
|
|
237
|
+
state.activeTab = tab;
|
|
238
|
+
els.tabs.forEach(function (btn) {
|
|
239
|
+
btn.classList.toggle("active", btn.dataset.tab === tab);
|
|
240
|
+
});
|
|
241
|
+
els.planPanel.hidden = tab !== "plan";
|
|
242
|
+
els.artifactsPanel.hidden = tab !== "artifacts";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function selectPlan(slug) {
|
|
246
|
+
state.selectedSlug = slug;
|
|
247
|
+
renderRail();
|
|
248
|
+
renderDetail();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
els.tabs.forEach(function (btn) {
|
|
252
|
+
btn.addEventListener("click", function () { setActiveTab(btn.dataset.tab); });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
function upsertPlan(plan) {
|
|
256
|
+
state.plans.set(plan.slug, plan);
|
|
257
|
+
renderRail();
|
|
258
|
+
if (state.selectedSlug === plan.slug) renderDetail();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function removePlan(slug) {
|
|
262
|
+
state.plans.delete(slug);
|
|
263
|
+
state.artifacts.delete(slug);
|
|
264
|
+
if (state.selectedSlug === slug) state.selectedSlug = null;
|
|
265
|
+
renderRail();
|
|
266
|
+
renderDetail();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function upsertArtifact(artifact) {
|
|
270
|
+
var bucket = state.artifacts.get(artifact.plan_slug);
|
|
271
|
+
if (!bucket) {
|
|
272
|
+
bucket = new Map();
|
|
273
|
+
state.artifacts.set(artifact.plan_slug, bucket);
|
|
274
|
+
}
|
|
275
|
+
bucket.set(artifact.slug, artifact);
|
|
276
|
+
renderRail();
|
|
277
|
+
if (state.selectedSlug === artifact.plan_slug) renderDetail();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function removeArtifact(planSlug, slug) {
|
|
281
|
+
var bucket = state.artifacts.get(planSlug);
|
|
282
|
+
if (!bucket) return;
|
|
283
|
+
bucket.delete(slug);
|
|
284
|
+
renderRail();
|
|
285
|
+
if (state.selectedSlug === planSlug) renderDetail();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function loadInitial() {
|
|
289
|
+
try {
|
|
290
|
+
var res = await fetch("/api/claude-plans");
|
|
291
|
+
var data = await res.json();
|
|
292
|
+
if (data && Array.isArray(data.plans)) {
|
|
293
|
+
data.plans.forEach(function (p) { state.plans.set(p.slug, p); });
|
|
294
|
+
}
|
|
295
|
+
if (data && data.storage) els.storage.textContent = data.storage;
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn("[claude-plans] failed to load initial state:", err);
|
|
298
|
+
}
|
|
299
|
+
// Preload artifacts for visible plans
|
|
300
|
+
var slugs = Array.from(state.plans.keys());
|
|
301
|
+
await Promise.all(slugs.map(function (slug) {
|
|
302
|
+
return fetch("/api/claude-plans/" + encodeURIComponent(slug))
|
|
303
|
+
.then(function (r) { return r.json(); })
|
|
304
|
+
.then(function (data) {
|
|
305
|
+
if (data && Array.isArray(data.artifacts)) {
|
|
306
|
+
var bucket = new Map();
|
|
307
|
+
data.artifacts.forEach(function (a) { bucket.set(a.slug, a); });
|
|
308
|
+
state.artifacts.set(slug, bucket);
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
.catch(function () { /* ignore */ });
|
|
312
|
+
}));
|
|
313
|
+
renderRail();
|
|
314
|
+
if (!state.selectedSlug) {
|
|
315
|
+
var sorted = sortedPlans();
|
|
316
|
+
if (sorted.length > 0) selectPlan(sorted[0].slug);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function startStream() {
|
|
321
|
+
var es = new EventSource("/api/claude-plans/stream");
|
|
322
|
+
es.addEventListener("plan:upsert", function (e) {
|
|
323
|
+
try { upsertPlan(JSON.parse(e.data).plan); } catch (_) {}
|
|
324
|
+
});
|
|
325
|
+
es.addEventListener("plan:delete", function (e) {
|
|
326
|
+
try { removePlan(JSON.parse(e.data).slug); } catch (_) {}
|
|
327
|
+
});
|
|
328
|
+
es.addEventListener("artifact:upsert", function (e) {
|
|
329
|
+
try { upsertArtifact(JSON.parse(e.data).artifact); } catch (_) {}
|
|
330
|
+
});
|
|
331
|
+
es.addEventListener("artifact:delete", function (e) {
|
|
332
|
+
try {
|
|
333
|
+
var payload = JSON.parse(e.data);
|
|
334
|
+
removeArtifact(payload.plan_slug, payload.slug);
|
|
335
|
+
} catch (_) {}
|
|
336
|
+
});
|
|
337
|
+
es.addEventListener("plan:focus", function (e) {
|
|
338
|
+
try {
|
|
339
|
+
var data = JSON.parse(e.data);
|
|
340
|
+
if (data.slug) {
|
|
341
|
+
selectPlan(data.slug);
|
|
342
|
+
var item = els.list.querySelector("[data-slug=\"" + data.slug + "\"]");
|
|
343
|
+
if (item) item.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
344
|
+
}
|
|
345
|
+
} catch (_) {}
|
|
346
|
+
});
|
|
347
|
+
es.onerror = function () { /* EventSource auto-reconnects */ };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
351
|
+
setActiveTab("plan");
|
|
352
|
+
loadInitial().then(startStream);
|
|
353
|
+
});
|
|
354
|
+
})();
|
package/public/index.html
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
</div>
|
|
15
15
|
<div class="header-right">
|
|
16
16
|
<button class="btn-refresh" id="refresh-btn" title="Refresh scopes and update sets">Refresh</button>
|
|
17
|
+
<a class="btn-refresh" href="/claude-plans" title="Claude plans and diagrams pushed via MCP">Claude</a>
|
|
17
18
|
<div class="active-task-chip" id="active-task-chip" style="display:none;"></div>
|
|
18
19
|
<div class="instance-badge" id="instance-badge">Loading...</div>
|
|
19
20
|
</div>
|
package/public/styles.css
CHANGED
|
@@ -717,6 +717,11 @@ header h1 span {
|
|
|
717
717
|
border-color: var(--danger);
|
|
718
718
|
}
|
|
719
719
|
|
|
720
|
+
.toast.warn {
|
|
721
|
+
border-color: #c88c2a;
|
|
722
|
+
color: #e8b86d;
|
|
723
|
+
}
|
|
724
|
+
|
|
720
725
|
@keyframes slideIn {
|
|
721
726
|
from {
|
|
722
727
|
opacity: 0;
|
|
@@ -817,3 +822,24 @@ header h1 span {
|
|
|
817
822
|
color: var(--text-muted);
|
|
818
823
|
white-space: nowrap;
|
|
819
824
|
}
|
|
825
|
+
|
|
826
|
+
.recent-edit-dismiss {
|
|
827
|
+
background: none;
|
|
828
|
+
border: none;
|
|
829
|
+
color: var(--text-muted);
|
|
830
|
+
font-size: 16px;
|
|
831
|
+
line-height: 1;
|
|
832
|
+
padding: 0 2px;
|
|
833
|
+
cursor: pointer;
|
|
834
|
+
opacity: 0;
|
|
835
|
+
transition: opacity 0.15s, color 0.15s;
|
|
836
|
+
flex-shrink: 0;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
.recent-edit-row:hover .recent-edit-dismiss {
|
|
840
|
+
opacity: 1;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
.recent-edit-dismiss:hover {
|
|
844
|
+
color: var(--danger);
|
|
845
|
+
}
|
package/server.js
CHANGED
|
@@ -23,6 +23,11 @@ const recentEditsLimiter = RateLimit({
|
|
|
23
23
|
windowMs: 15 * 60 * 1000,
|
|
24
24
|
max: 100,
|
|
25
25
|
});
|
|
26
|
+
// Rate limiter for claude-plans destructive operations
|
|
27
|
+
const claudePlansLimiter = RateLimit({
|
|
28
|
+
windowMs: 15 * 60 * 1000,
|
|
29
|
+
max: 60,
|
|
30
|
+
});
|
|
26
31
|
const SN_PASSWORD = process.env.SN_PASSWORD || "";
|
|
27
32
|
const BASE_URL = `https://${SN_INSTANCE}`;
|
|
28
33
|
|
|
@@ -279,6 +284,7 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
|
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
enriched.push({
|
|
287
|
+
sys_id: edit.sys_id,
|
|
282
288
|
tableName: edit.tableName,
|
|
283
289
|
name: edit.name,
|
|
284
290
|
scope: edit.scope,
|
|
@@ -293,6 +299,28 @@ app.get("/api/recent-edits", recentEditsLimiter, async function (req, res) {
|
|
|
293
299
|
}
|
|
294
300
|
});
|
|
295
301
|
|
|
302
|
+
// POST /api/recent-edits/dismiss — remove one entry from the local recent edits file
|
|
303
|
+
app.post("/api/recent-edits/dismiss", function (req, res) {
|
|
304
|
+
try {
|
|
305
|
+
var sys_id = req.body.sys_id;
|
|
306
|
+
var tableName = req.body.tableName;
|
|
307
|
+
if (!sys_id || !tableName) {
|
|
308
|
+
return res.status(400).json({ error: "sys_id and tableName required" });
|
|
309
|
+
}
|
|
310
|
+
var edits = [];
|
|
311
|
+
if (fs.existsSync(RECENT_EDITS_FILE)) {
|
|
312
|
+
edits = JSON.parse(fs.readFileSync(RECENT_EDITS_FILE, "utf8"));
|
|
313
|
+
}
|
|
314
|
+
var filtered = edits.filter(function (e) {
|
|
315
|
+
return !(e.sys_id === sys_id && e.tableName === tableName);
|
|
316
|
+
});
|
|
317
|
+
fs.writeFileSync(RECENT_EDITS_FILE, JSON.stringify(filtered, null, 2));
|
|
318
|
+
res.json({ ok: true });
|
|
319
|
+
} catch (e) {
|
|
320
|
+
res.status(500).json({ error: e.message });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
296
324
|
// GET /api/update-sets/:scope — list in-progress update sets for a scope
|
|
297
325
|
app.get("/api/update-sets/:scope", async (req, res) => {
|
|
298
326
|
try {
|
|
@@ -772,14 +800,244 @@ app.post("/api/clickup/deselect-task", function (req, res) {
|
|
|
772
800
|
}
|
|
773
801
|
});
|
|
774
802
|
|
|
803
|
+
// --- Claude Plans Panel ---
|
|
804
|
+
// Reads JSON records written by @tenonhq/dovetail-claude-plans into
|
|
805
|
+
// ~/.dovetail/claude-plans/ and streams updates to the /claude-plans page.
|
|
806
|
+
// Storage layout:
|
|
807
|
+
// <root>/<plan-slug>.json
|
|
808
|
+
// <root>/<plan-slug>/artifacts/<artifact-slug>.json
|
|
809
|
+
const os = require("os");
|
|
810
|
+
const chokidar = require("chokidar");
|
|
811
|
+
|
|
812
|
+
const CLAUDE_PLANS_DIR =
|
|
813
|
+
process.env.DOVE_CLAUDE_PLANS_DIR ||
|
|
814
|
+
path.join(os.homedir(), ".dovetail", "claude-plans");
|
|
815
|
+
|
|
816
|
+
// Slugs are written by @tenonhq/dovetail-claude-plans' slugify() — kebab-case,
|
|
817
|
+
// max 64 chars. We re-validate on the read side so a request like `..%2Ffoo`
|
|
818
|
+
// cannot escape CLAUDE_PLANS_DIR via path.join.
|
|
819
|
+
const CLAUDE_PLAN_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
820
|
+
|
|
821
|
+
function isValidSlug(slug) {
|
|
822
|
+
return typeof slug === "string" && CLAUDE_PLAN_SLUG.test(slug);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function planFilePath(slug) {
|
|
826
|
+
return path.join(CLAUDE_PLANS_DIR, slug + ".json");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function artifactsDirFor(slug) {
|
|
830
|
+
return path.join(CLAUDE_PLANS_DIR, slug, "artifacts");
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function safeReadJson(filePath) {
|
|
834
|
+
try {
|
|
835
|
+
if (!fs.existsSync(filePath)) return null;
|
|
836
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
837
|
+
} catch (e) {
|
|
838
|
+
return null;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function listClaudePlans() {
|
|
843
|
+
if (!fs.existsSync(CLAUDE_PLANS_DIR)) return [];
|
|
844
|
+
const entries = fs.readdirSync(CLAUDE_PLANS_DIR);
|
|
845
|
+
const plans = [];
|
|
846
|
+
for (let i = 0; i < entries.length; i++) {
|
|
847
|
+
const name = entries[i];
|
|
848
|
+
if (!name.endsWith(".json")) continue;
|
|
849
|
+
const plan = safeReadJson(path.join(CLAUDE_PLANS_DIR, name));
|
|
850
|
+
if (plan) plans.push(plan);
|
|
851
|
+
}
|
|
852
|
+
plans.sort(function (a, b) {
|
|
853
|
+
return (b.updated_at || "").localeCompare(a.updated_at || "");
|
|
854
|
+
});
|
|
855
|
+
return plans;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function listClaudeArtifacts(slug) {
|
|
859
|
+
if (!isValidSlug(slug)) return [];
|
|
860
|
+
const dir = artifactsDirFor(slug);
|
|
861
|
+
if (!fs.existsSync(dir)) return [];
|
|
862
|
+
const entries = fs.readdirSync(dir);
|
|
863
|
+
const artifacts = [];
|
|
864
|
+
for (let i = 0; i < entries.length; i++) {
|
|
865
|
+
if (!entries[i].endsWith(".json")) continue;
|
|
866
|
+
const a = safeReadJson(path.join(dir, entries[i]));
|
|
867
|
+
if (a) artifacts.push(a);
|
|
868
|
+
}
|
|
869
|
+
artifacts.sort(function (a, b) {
|
|
870
|
+
return (a.created_at || "").localeCompare(b.created_at || "");
|
|
871
|
+
});
|
|
872
|
+
return artifacts;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Parse a watcher path like "<root>/<slug>.json" or
|
|
876
|
+
// "<root>/<slug>/artifacts/<artifact-slug>.json" into { kind, slug, artifactSlug }.
|
|
877
|
+
function classifyPath(filePath) {
|
|
878
|
+
const rel = path.relative(CLAUDE_PLANS_DIR, filePath);
|
|
879
|
+
if (!rel || rel.startsWith("..")) return null;
|
|
880
|
+
const parts = rel.split(path.sep);
|
|
881
|
+
if (parts.length === 1 && parts[0] === ".focus") {
|
|
882
|
+
return { kind: "focus" };
|
|
883
|
+
}
|
|
884
|
+
if (parts.length === 1 && parts[0].endsWith(".json")) {
|
|
885
|
+
return { kind: "plan", slug: parts[0].slice(0, -5) };
|
|
886
|
+
}
|
|
887
|
+
if (parts.length === 3 && parts[1] === "artifacts" && parts[2].endsWith(".json")) {
|
|
888
|
+
return { kind: "artifact", slug: parts[0], artifactSlug: parts[2].slice(0, -5) };
|
|
889
|
+
}
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// SSE fan-out. Each connected client is a response object held open until the
|
|
894
|
+
// client disconnects; broadcastClaudePlanEvent writes a single SSE frame to all.
|
|
895
|
+
const claudePlanSseClients = new Set();
|
|
896
|
+
|
|
897
|
+
function broadcastClaudePlanEvent(event, data) {
|
|
898
|
+
const frame = "event: " + event + "\ndata: " + JSON.stringify(data) + "\n\n";
|
|
899
|
+
for (const res of claudePlanSseClients) {
|
|
900
|
+
try {
|
|
901
|
+
res.write(frame);
|
|
902
|
+
} catch (err) {
|
|
903
|
+
claudePlanSseClients.delete(res);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function handleWatcherChange(event, filePath) {
|
|
909
|
+
const info = classifyPath(filePath);
|
|
910
|
+
if (!info) return;
|
|
911
|
+
if (info.kind === "focus") {
|
|
912
|
+
const focus = safeReadJson(filePath);
|
|
913
|
+
if (focus && isValidSlug(focus.slug)) {
|
|
914
|
+
broadcastClaudePlanEvent("plan:focus", { slug: focus.slug });
|
|
915
|
+
}
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (info.kind === "plan") {
|
|
919
|
+
if (event === "unlink") {
|
|
920
|
+
broadcastClaudePlanEvent("plan:delete", { slug: info.slug });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const plan = safeReadJson(filePath);
|
|
924
|
+
if (plan) broadcastClaudePlanEvent("plan:upsert", { plan: plan });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (info.kind === "artifact") {
|
|
928
|
+
if (event === "unlink") {
|
|
929
|
+
broadcastClaudePlanEvent("artifact:delete", {
|
|
930
|
+
plan_slug: info.slug,
|
|
931
|
+
slug: info.artifactSlug
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const artifact = safeReadJson(filePath);
|
|
936
|
+
if (artifact) broadcastClaudePlanEvent("artifact:upsert", { artifact: artifact });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Start the watcher lazily — create the storage dir if missing so chokidar has
|
|
941
|
+
// something to watch. ignoreInitial avoids replaying every file on boot
|
|
942
|
+
// (clients fetch initial state via GET /api/claude-plans).
|
|
943
|
+
let claudePlanWatcher = null;
|
|
944
|
+
function startClaudePlanWatcher() {
|
|
945
|
+
if (claudePlanWatcher) return;
|
|
946
|
+
try {
|
|
947
|
+
fs.mkdirSync(CLAUDE_PLANS_DIR, { recursive: true });
|
|
948
|
+
} catch (err) {
|
|
949
|
+
console.warn("[claude-plans] could not create storage dir:", err.message);
|
|
950
|
+
}
|
|
951
|
+
claudePlanWatcher = chokidar.watch(CLAUDE_PLANS_DIR, {
|
|
952
|
+
ignoreInitial: true,
|
|
953
|
+
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
|
954
|
+
depth: 3
|
|
955
|
+
});
|
|
956
|
+
claudePlanWatcher.on("add", function (p) { handleWatcherChange("add", p); });
|
|
957
|
+
claudePlanWatcher.on("change", function (p) { handleWatcherChange("change", p); });
|
|
958
|
+
claudePlanWatcher.on("unlink", function (p) { handleWatcherChange("unlink", p); });
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
app.get("/claude-plans", function (req, res) {
|
|
962
|
+
res.sendFile(path.join(__dirname, "public", "claude-plans.html"));
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
app.get("/api/claude-plans", function (req, res) {
|
|
966
|
+
try {
|
|
967
|
+
res.json({ plans: listClaudePlans(), storage: CLAUDE_PLANS_DIR });
|
|
968
|
+
} catch (e) {
|
|
969
|
+
res.status(500).json({ error: e.message });
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
app.get("/api/claude-plans/stream", function (req, res) {
|
|
974
|
+
res.set({
|
|
975
|
+
"Content-Type": "text/event-stream",
|
|
976
|
+
"Cache-Control": "no-cache",
|
|
977
|
+
Connection: "keep-alive",
|
|
978
|
+
"X-Accel-Buffering": "no"
|
|
979
|
+
});
|
|
980
|
+
res.flushHeaders();
|
|
981
|
+
res.write("event: hello\ndata: {}\n\n");
|
|
982
|
+
claudePlanSseClients.add(res);
|
|
983
|
+
|
|
984
|
+
const heartbeat = setInterval(function () {
|
|
985
|
+
try {
|
|
986
|
+
res.write(": heartbeat\n\n");
|
|
987
|
+
} catch (err) {
|
|
988
|
+
clearInterval(heartbeat);
|
|
989
|
+
claudePlanSseClients.delete(res);
|
|
990
|
+
}
|
|
991
|
+
}, 25000);
|
|
992
|
+
|
|
993
|
+
req.on("close", function () {
|
|
994
|
+
clearInterval(heartbeat);
|
|
995
|
+
claudePlanSseClients.delete(res);
|
|
996
|
+
});
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// :slug must avoid the static "stream" route above; Express matches in order.
|
|
1000
|
+
app.get("/api/claude-plans/:slug", function (req, res) {
|
|
1001
|
+
try {
|
|
1002
|
+
const slug = req.params.slug;
|
|
1003
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1004
|
+
const plan = safeReadJson(planFilePath(slug));
|
|
1005
|
+
if (!plan) return res.status(404).json({ error: "plan not found" });
|
|
1006
|
+
res.json({ plan: plan, artifacts: listClaudeArtifacts(slug) });
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
res.status(500).json({ error: e.message });
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
app.delete("/api/claude-plans/:slug", claudePlansLimiter, function (req, res) {
|
|
1013
|
+
try {
|
|
1014
|
+
var slug = req.params.slug;
|
|
1015
|
+
if (!isValidSlug(slug)) return res.status(400).json({ error: "invalid slug" });
|
|
1016
|
+
var baseDir = path.resolve(CLAUDE_PLANS_DIR);
|
|
1017
|
+
var planFile = path.resolve(baseDir, slug + ".json");
|
|
1018
|
+
if (!planFile.startsWith(baseDir + path.sep)) return res.status(400).json({ error: "invalid slug" });
|
|
1019
|
+
if (!fs.existsSync(planFile)) return res.status(404).json({ error: "plan not found" });
|
|
1020
|
+
fs.unlinkSync(planFile);
|
|
1021
|
+
var artifactsDir = path.resolve(baseDir, slug);
|
|
1022
|
+
if (artifactsDir.startsWith(baseDir + path.sep) && fs.existsSync(artifactsDir)) {
|
|
1023
|
+
fs.rmSync(artifactsDir, { recursive: true, force: true });
|
|
1024
|
+
}
|
|
1025
|
+
res.json({ deleted: true });
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
res.status(500).json({ error: e.message });
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
775
1031
|
// Only start the server when run directly (not when require()-d).
|
|
776
1032
|
// Callers like dashboardCommand.ts and allScopesCommands.ts use
|
|
777
1033
|
// spawn("node", [serverPath]) which sets require.main === module.
|
|
778
1034
|
if (require.main === module) {
|
|
1035
|
+
startClaudePlanWatcher();
|
|
779
1036
|
app.listen(PORT, "127.0.0.1", function () {
|
|
780
1037
|
console.log("\n Dovetail Update Set Dashboard");
|
|
781
1038
|
console.log(" Instance: " + SN_INSTANCE);
|
|
782
1039
|
console.log(" Project: " + PROJECT_ROOT);
|
|
783
|
-
console.log(" Dashboard: http://localhost:" + PORT
|
|
1040
|
+
console.log(" Dashboard: http://localhost:" + PORT);
|
|
1041
|
+
console.log(" Claude: http://localhost:" + PORT + "/claude-plans\n");
|
|
784
1042
|
});
|
|
785
1043
|
}
|