@a5c-ai/babysitter-breakpoints 0.1.3 → 0.1.4
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 +36 -6
- package/api/db.js +26 -1
- package/api/server.js +12 -0
- package/bin/breakpoints.js +2 -1
- package/db/migrations/001_init.sql +56 -0
- package/db/migrations/002_extensions.sql +13 -0
- package/db/schema.sql +60 -0
- package/package.json +29 -26
- package/scripts/dev-runner.js +41 -0
- package/scripts/dev.ps1 +10 -0
- package/scripts/dev.sh +13 -0
- package/scripts/init-db.js +20 -0
- package/web/app.js +511 -0
- package/web/index.html +151 -0
- package/web/server.js +14 -0
- package/web/styles.css +431 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
const tokenInput = document.getElementById("token");
|
|
2
|
+
const saveTokenBtn = document.getElementById("save-token");
|
|
3
|
+
const statusSelect = document.getElementById("status");
|
|
4
|
+
const tagInput = document.getElementById("tag");
|
|
5
|
+
const agentInput = document.getElementById("agentId");
|
|
6
|
+
const refreshBtn = document.getElementById("refresh");
|
|
7
|
+
const listEl = document.getElementById("breakpoint-list");
|
|
8
|
+
const listMessage = document.getElementById("list-message");
|
|
9
|
+
const autoRefreshSelect = document.getElementById("auto-refresh");
|
|
10
|
+
const notifySelect = document.getElementById("notify");
|
|
11
|
+
const notifyStatus = document.getElementById("notify-status");
|
|
12
|
+
|
|
13
|
+
const statWaiting = document.getElementById("stat-waiting");
|
|
14
|
+
const statReleased = document.getElementById("stat-released");
|
|
15
|
+
const statExpired = document.getElementById("stat-expired");
|
|
16
|
+
const statCancelled = document.getElementById("stat-cancelled");
|
|
17
|
+
|
|
18
|
+
const detailEmpty = document.getElementById("detail-empty");
|
|
19
|
+
const detailView = document.getElementById("detail-view");
|
|
20
|
+
const detailTitle = document.getElementById("detail-title");
|
|
21
|
+
const detailStatus = document.getElementById("detail-status");
|
|
22
|
+
const detailMeta = document.getElementById("detail-meta");
|
|
23
|
+
const detailPayload = document.getElementById("detail-payload");
|
|
24
|
+
const feedbackList = document.getElementById("feedback-list");
|
|
25
|
+
const feedbackText = document.getElementById("feedback-text");
|
|
26
|
+
const feedbackAuthor = document.getElementById("feedback-author");
|
|
27
|
+
const sendFeedbackBtn = document.getElementById("send-feedback");
|
|
28
|
+
const releaseBtn = document.getElementById("release");
|
|
29
|
+
const feedbackMessage = document.getElementById("feedback-message");
|
|
30
|
+
const contextList = document.getElementById("context-list");
|
|
31
|
+
const contextEmpty = document.getElementById("context-empty");
|
|
32
|
+
const contextMeta = document.getElementById("context-meta");
|
|
33
|
+
const contextRender = document.getElementById("context-render");
|
|
34
|
+
const contextCode = document.getElementById("context-code");
|
|
35
|
+
const contextCodeInner = document.getElementById("context-code-inner");
|
|
36
|
+
const extensionsMessage = document.getElementById("extensions-message");
|
|
37
|
+
const extensionsList = document.getElementById("extensions-list");
|
|
38
|
+
|
|
39
|
+
const STORAGE_KEY = "bp_token";
|
|
40
|
+
const STORAGE_REFRESH_KEY = "bp_auto_refresh";
|
|
41
|
+
const STORAGE_NOTIFY_KEY = "bp_notify";
|
|
42
|
+
let currentId = null;
|
|
43
|
+
let currentStatus = null;
|
|
44
|
+
let contextFiles = [];
|
|
45
|
+
let refreshTimer = null;
|
|
46
|
+
let lastWaitingIds = new Set();
|
|
47
|
+
|
|
48
|
+
function getToken() {
|
|
49
|
+
return localStorage.getItem(STORAGE_KEY) || "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setToken(value) {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getAutoRefresh() {
|
|
57
|
+
return localStorage.getItem(STORAGE_REFRESH_KEY) || "10";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setAutoRefresh(value) {
|
|
61
|
+
localStorage.setItem(STORAGE_REFRESH_KEY, value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getNotifySetting() {
|
|
65
|
+
return localStorage.getItem(STORAGE_NOTIFY_KEY) || "off";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setNotifySetting(value) {
|
|
69
|
+
localStorage.setItem(STORAGE_NOTIFY_KEY, value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function authHeaders() {
|
|
73
|
+
const token = getToken();
|
|
74
|
+
if (!token) return {};
|
|
75
|
+
return { Authorization: `Bearer ${token}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function apiBase() {
|
|
79
|
+
if (window.API_BASE) return window.API_BASE;
|
|
80
|
+
const host = window.location.hostname || "localhost";
|
|
81
|
+
return `http://${host}:3185`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function setMessage(text) {
|
|
85
|
+
listMessage.textContent = text || "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function updateNotifyStatus() {
|
|
89
|
+
if (!notifyStatus) return;
|
|
90
|
+
if (!("Notification" in window)) {
|
|
91
|
+
notifyStatus.textContent = "Notifications not supported in this browser.";
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (Notification.permission === "denied") {
|
|
95
|
+
notifyStatus.textContent = "Notifications blocked in browser settings.";
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (Notification.permission === "default") {
|
|
99
|
+
notifyStatus.textContent = "Enable to request permission.";
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
notifyStatus.textContent = "Notifications enabled.";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchJson(url, options = {}) {
|
|
106
|
+
const target = url.startsWith("http") ? url : `${apiBase()}${url}`;
|
|
107
|
+
const res = await fetch(target, {
|
|
108
|
+
...options,
|
|
109
|
+
headers: {
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
...authHeaders(),
|
|
112
|
+
...(options.headers || {}),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
const body = await res.json().catch(() => ({}));
|
|
117
|
+
throw new Error(body.error || `Request failed (${res.status})`);
|
|
118
|
+
}
|
|
119
|
+
return res.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setStats(items) {
|
|
123
|
+
const counts = {
|
|
124
|
+
waiting: 0,
|
|
125
|
+
released: 0,
|
|
126
|
+
expired: 0,
|
|
127
|
+
cancelled: 0,
|
|
128
|
+
};
|
|
129
|
+
items.forEach((item) => {
|
|
130
|
+
if (counts[item.status] !== undefined) counts[item.status] += 1;
|
|
131
|
+
});
|
|
132
|
+
statWaiting.textContent = counts.waiting;
|
|
133
|
+
statReleased.textContent = counts.released;
|
|
134
|
+
statExpired.textContent = counts.expired;
|
|
135
|
+
statCancelled.textContent = counts.cancelled;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function statusClass(status) {
|
|
139
|
+
return status ? `status-pill ${status}` : "status-pill";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getContextFiles(payload) {
|
|
143
|
+
const context = payload?.context || {};
|
|
144
|
+
if (Array.isArray(context.files)) {
|
|
145
|
+
return context.files
|
|
146
|
+
.filter((item) => item && typeof item.path === "string")
|
|
147
|
+
.map((item) => ({
|
|
148
|
+
path: item.path,
|
|
149
|
+
label: item.label || item.path,
|
|
150
|
+
format: item.format || null,
|
|
151
|
+
language: item.language || null,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(context.paths)) {
|
|
155
|
+
return context.paths
|
|
156
|
+
.filter((item) => typeof item === "string")
|
|
157
|
+
.map((item) => ({ path: item, label: item, format: null, language: null }));
|
|
158
|
+
}
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderContextList() {
|
|
163
|
+
contextList.innerHTML = "";
|
|
164
|
+
contextMeta.textContent = "";
|
|
165
|
+
contextRender.innerHTML = "";
|
|
166
|
+
contextCodeInner.textContent = "";
|
|
167
|
+
contextCode.classList.add("hidden");
|
|
168
|
+
if (!contextFiles.length) {
|
|
169
|
+
contextEmpty.classList.remove("hidden");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
contextEmpty.classList.add("hidden");
|
|
173
|
+
contextFiles.forEach((file, index) => {
|
|
174
|
+
const li = document.createElement("li");
|
|
175
|
+
li.className = "context-item";
|
|
176
|
+
li.textContent = file.label;
|
|
177
|
+
li.addEventListener("click", () => {
|
|
178
|
+
document
|
|
179
|
+
.querySelectorAll(".context-item")
|
|
180
|
+
.forEach((el) => el.classList.remove("active"));
|
|
181
|
+
li.classList.add("active");
|
|
182
|
+
loadContextFile(file);
|
|
183
|
+
});
|
|
184
|
+
contextList.appendChild(li);
|
|
185
|
+
if (index === 0) {
|
|
186
|
+
li.classList.add("active");
|
|
187
|
+
loadContextFile(file);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function loadContextFile(file) {
|
|
193
|
+
if (!currentId) return;
|
|
194
|
+
contextMeta.textContent = `Loading ${file.path}...`;
|
|
195
|
+
contextRender.innerHTML = "";
|
|
196
|
+
contextCodeInner.textContent = "";
|
|
197
|
+
contextCode.classList.add("hidden");
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetchJson(
|
|
200
|
+
`/api/breakpoints/${currentId}/context?path=${encodeURIComponent(file.path)}`
|
|
201
|
+
);
|
|
202
|
+
contextMeta.textContent = `${res.path} | ${res.format} | ${res.language}`;
|
|
203
|
+
if (res.format === "markdown") {
|
|
204
|
+
contextRender.innerHTML = window.marked.parse(res.content || "");
|
|
205
|
+
contextCode.classList.add("hidden");
|
|
206
|
+
contextRender.classList.remove("hidden");
|
|
207
|
+
} else {
|
|
208
|
+
contextCodeInner.textContent = res.content || "";
|
|
209
|
+
contextCode.classList.remove("hidden");
|
|
210
|
+
contextRender.classList.add("hidden");
|
|
211
|
+
window.hljs.highlightElement(contextCodeInner);
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
contextMeta.textContent = err.message;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderList(items) {
|
|
219
|
+
listEl.innerHTML = "";
|
|
220
|
+
if (!items.length) {
|
|
221
|
+
listEl.innerHTML = "<li class=\"hint\">No breakpoints found.</li>";
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
items.forEach((item) => {
|
|
225
|
+
const li = document.createElement("li");
|
|
226
|
+
li.className = "breakpoint-card";
|
|
227
|
+
li.innerHTML = `
|
|
228
|
+
<div><strong>${item.title || item.id}</strong></div>
|
|
229
|
+
<div class="hint">${item.agentId || "unknown agent"}</div>
|
|
230
|
+
<div class="hint">${item.createdAt}</div>
|
|
231
|
+
<span class="${statusClass(item.status)}">${item.status}</span>
|
|
232
|
+
`;
|
|
233
|
+
li.addEventListener("click", () => loadDetail(item.id));
|
|
234
|
+
listEl.appendChild(li);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderDetail(data) {
|
|
239
|
+
currentStatus = data.status;
|
|
240
|
+
detailTitle.textContent = data.title || data.breakpointId;
|
|
241
|
+
detailStatus.textContent = data.status;
|
|
242
|
+
detailStatus.className = statusClass(data.status);
|
|
243
|
+
detailMeta.textContent = `Agent: ${data.agentId || "unknown"} | Created: ${
|
|
244
|
+
data.createdAt
|
|
245
|
+
}`;
|
|
246
|
+
detailPayload.textContent = JSON.stringify(data.payload, null, 2);
|
|
247
|
+
feedbackList.innerHTML = "";
|
|
248
|
+
if (!data.feedback.length) {
|
|
249
|
+
feedbackList.innerHTML = "<li class=\"hint\">No feedback yet.</li>";
|
|
250
|
+
} else {
|
|
251
|
+
data.feedback.forEach((item) => {
|
|
252
|
+
const li = document.createElement("li");
|
|
253
|
+
li.className = "feedback-item";
|
|
254
|
+
li.innerHTML = `<strong>${item.author}</strong><div>${item.comment}</div><div class="hint">${item.createdAt}</div>`;
|
|
255
|
+
feedbackList.appendChild(li);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
detailEmpty.classList.add("hidden");
|
|
259
|
+
detailView.classList.remove("hidden");
|
|
260
|
+
|
|
261
|
+
if (data.status !== "waiting") {
|
|
262
|
+
sendFeedbackBtn.classList.add("disabled");
|
|
263
|
+
releaseBtn.classList.add("disabled");
|
|
264
|
+
} else {
|
|
265
|
+
sendFeedbackBtn.classList.remove("disabled");
|
|
266
|
+
releaseBtn.classList.remove("disabled");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
contextFiles = getContextFiles(data.payload);
|
|
270
|
+
renderContextList();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function loadList() {
|
|
274
|
+
setMessage("Loading...");
|
|
275
|
+
const params = new URLSearchParams();
|
|
276
|
+
if (statusSelect.value) params.set("status", statusSelect.value);
|
|
277
|
+
if (tagInput.value) params.set("tag", tagInput.value);
|
|
278
|
+
if (agentInput.value) params.set("agentId", agentInput.value);
|
|
279
|
+
const data = await fetchJson(`/api/breakpoints?${params.toString()}`);
|
|
280
|
+
const items = data.items || [];
|
|
281
|
+
updateWaitingNotifications(items);
|
|
282
|
+
setStats(items);
|
|
283
|
+
renderList(items);
|
|
284
|
+
setMessage("");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function maskValue(value) {
|
|
288
|
+
if (!value) return "";
|
|
289
|
+
if (value.length <= 6) return "***";
|
|
290
|
+
return `${value.slice(0, 3)}***${value.slice(-3)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function loadExtensions() {
|
|
294
|
+
extensionsMessage.textContent = "Loading...";
|
|
295
|
+
const data = await fetchJson("/api/extensions");
|
|
296
|
+
const items = data.items || [];
|
|
297
|
+
extensionsList.innerHTML = "";
|
|
298
|
+
items.forEach((item) => {
|
|
299
|
+
const card = document.createElement("div");
|
|
300
|
+
card.className = "extension-card";
|
|
301
|
+
const isEnabled = item.enabled;
|
|
302
|
+
const tokenValue = item.config?.token ? maskValue(item.config.token) : "";
|
|
303
|
+
if (item.name === "telegram") {
|
|
304
|
+
card.innerHTML = `
|
|
305
|
+
<div class="extension-header">
|
|
306
|
+
<div><strong>${item.name}</strong></div>
|
|
307
|
+
<div class="status-pill ${isEnabled ? "released" : "cancelled"}">
|
|
308
|
+
${isEnabled ? "enabled" : "disabled"}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="extension-fields">
|
|
312
|
+
<div>
|
|
313
|
+
<label>Bot Token</label>
|
|
314
|
+
<input data-field="token" type="password" value="${tokenValue}" placeholder="Telegram bot token" />
|
|
315
|
+
</div>
|
|
316
|
+
<div>
|
|
317
|
+
<label>User Handle</label>
|
|
318
|
+
<input data-field="username" type="text" value="${item.config?.username || ""}" placeholder="@yourhandle" />
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="extension-help">Send /start to the bot. The worker will attach chat/user IDs automatically.</div>
|
|
322
|
+
<div class="extension-actions">
|
|
323
|
+
<button data-action="save">Save</button>
|
|
324
|
+
<button data-action="toggle">${isEnabled ? "Disable" : "Enable"}</button>
|
|
325
|
+
</div>
|
|
326
|
+
`;
|
|
327
|
+
card.addEventListener("click", async (event) => {
|
|
328
|
+
const button = event.target.closest("button");
|
|
329
|
+
if (!button) return;
|
|
330
|
+
const action = button.getAttribute("data-action");
|
|
331
|
+
const tokenInput = card.querySelector("input[data-field='token']");
|
|
332
|
+
const userInput = card.querySelector("input[data-field='username']");
|
|
333
|
+
const token = tokenInput.value.trim();
|
|
334
|
+
const username = userInput.value.trim();
|
|
335
|
+
if (action === "save") {
|
|
336
|
+
if (!token) {
|
|
337
|
+
extensionsMessage.textContent = "Token required.";
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const config = {
|
|
341
|
+
token,
|
|
342
|
+
username,
|
|
343
|
+
chatId: item.config?.chatId,
|
|
344
|
+
allowedUserId: item.config?.allowedUserId,
|
|
345
|
+
};
|
|
346
|
+
try {
|
|
347
|
+
await fetchJson(`/api/extensions/telegram`, {
|
|
348
|
+
method: "POST",
|
|
349
|
+
body: JSON.stringify({ enabled: isEnabled, config }),
|
|
350
|
+
});
|
|
351
|
+
await loadExtensions();
|
|
352
|
+
} catch (err) {
|
|
353
|
+
extensionsMessage.textContent = err.message || "Save failed.";
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (action === "toggle") {
|
|
358
|
+
await fetchJson(`/api/extensions/telegram`, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
enabled: !isEnabled,
|
|
362
|
+
config: item.config || {},
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
await loadExtensions();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
card.innerHTML = `
|
|
370
|
+
<div class="extension-header">
|
|
371
|
+
<div><strong>${item.name}</strong></div>
|
|
372
|
+
<div class="status-pill ${isEnabled ? "released" : "cancelled"}">
|
|
373
|
+
${isEnabled ? "enabled" : "disabled"}
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="extension-actions">
|
|
377
|
+
<button data-action="toggle">${isEnabled ? "Disable" : "Enable"}</button>
|
|
378
|
+
</div>
|
|
379
|
+
`;
|
|
380
|
+
card.addEventListener("click", async (event) => {
|
|
381
|
+
const button = event.target.closest("button");
|
|
382
|
+
if (!button) return;
|
|
383
|
+
await fetchJson(`/api/extensions/${item.name}`, {
|
|
384
|
+
method: "POST",
|
|
385
|
+
body: JSON.stringify({ enabled: !isEnabled, config: item.config || {} }),
|
|
386
|
+
});
|
|
387
|
+
await loadExtensions();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
extensionsList.appendChild(card);
|
|
391
|
+
});
|
|
392
|
+
extensionsMessage.textContent = "";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function loadDetail(id) {
|
|
396
|
+
currentId = id;
|
|
397
|
+
const data = await fetchJson(`/api/breakpoints/${id}`);
|
|
398
|
+
renderDetail(data);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function sendFeedback(release) {
|
|
402
|
+
if (!currentId) return;
|
|
403
|
+
feedbackMessage.textContent = "";
|
|
404
|
+
if (currentStatus !== "waiting") {
|
|
405
|
+
feedbackMessage.textContent = "Breakpoint is not waiting.";
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const comment = feedbackText.value.trim();
|
|
409
|
+
const author = feedbackAuthor.value.trim();
|
|
410
|
+
if (!comment || !author) {
|
|
411
|
+
feedbackMessage.textContent = "Comment and author are required.";
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await fetchJson(`/api/breakpoints/${currentId}/feedback`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: JSON.stringify({ comment, author, release }),
|
|
417
|
+
});
|
|
418
|
+
feedbackText.value = "";
|
|
419
|
+
await loadDetail(currentId);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function updateWaitingNotifications(items) {
|
|
423
|
+
const waiting = items.filter((item) => item.status === "waiting");
|
|
424
|
+
const waitingIds = new Set(waiting.map((item) => item.id));
|
|
425
|
+
const newItems = waiting.filter((item) => !lastWaitingIds.has(item.id));
|
|
426
|
+
lastWaitingIds = waitingIds;
|
|
427
|
+
if (getNotifySetting() !== "on") return;
|
|
428
|
+
if (!newItems.length) return;
|
|
429
|
+
if (!("Notification" in window)) return;
|
|
430
|
+
if (Notification.permission !== "granted") return;
|
|
431
|
+
newItems.forEach((item) => {
|
|
432
|
+
new Notification("New breakpoint waiting", {
|
|
433
|
+
body: item.title || item.id,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function updateAutoRefresh() {
|
|
439
|
+
if (refreshTimer) {
|
|
440
|
+
clearInterval(refreshTimer);
|
|
441
|
+
refreshTimer = null;
|
|
442
|
+
}
|
|
443
|
+
const value = autoRefreshSelect.value;
|
|
444
|
+
setAutoRefresh(value);
|
|
445
|
+
if (value === "off") return;
|
|
446
|
+
const intervalMs = parseInt(value, 10) * 1000;
|
|
447
|
+
refreshTimer = setInterval(() => {
|
|
448
|
+
loadList().catch((err) => {
|
|
449
|
+
setMessage(err.message);
|
|
450
|
+
});
|
|
451
|
+
}, intervalMs);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
saveTokenBtn.addEventListener("click", () => {
|
|
455
|
+
setToken(tokenInput.value.trim());
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
refreshBtn.addEventListener("click", () => {
|
|
459
|
+
loadList().catch((err) => {
|
|
460
|
+
setMessage(err.message);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
sendFeedbackBtn.addEventListener("click", () => {
|
|
465
|
+
sendFeedback(false).catch((err) => {
|
|
466
|
+
feedbackMessage.textContent = err.message;
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
releaseBtn.addEventListener("click", () => {
|
|
471
|
+
sendFeedback(true).catch((err) => {
|
|
472
|
+
feedbackMessage.textContent = err.message;
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
tokenInput.value = getToken();
|
|
477
|
+
autoRefreshSelect.value = getAutoRefresh();
|
|
478
|
+
notifySelect.value = getNotifySetting();
|
|
479
|
+
updateAutoRefresh();
|
|
480
|
+
updateNotifyStatus();
|
|
481
|
+
|
|
482
|
+
autoRefreshSelect.addEventListener("change", updateAutoRefresh);
|
|
483
|
+
|
|
484
|
+
notifySelect.addEventListener("change", async () => {
|
|
485
|
+
const value = notifySelect.value;
|
|
486
|
+
setNotifySetting(value);
|
|
487
|
+
if (value === "on" && "Notification" in window) {
|
|
488
|
+
if (Notification.permission !== "granted") {
|
|
489
|
+
await Notification.requestPermission();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
updateNotifyStatus();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
notifySelect.addEventListener("change", () => {
|
|
496
|
+
if ("Notification" in window) {
|
|
497
|
+
if (Notification.permission === "granted") {
|
|
498
|
+
new Notification("Desktop alerts enabled", {
|
|
499
|
+
body: "You will be notified of new waiting breakpoints.",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
loadList().catch((err) => {
|
|
506
|
+
setMessage(err.message);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
loadExtensions().catch((err) => {
|
|
510
|
+
extensionsMessage.textContent = err.message;
|
|
511
|
+
});
|
package/web/index.html
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Breakpoint Manager</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link
|
|
10
|
+
href="https://fonts.googleapis.com/css2?family=Fraunces:wght@600;700&family=Space+Grotesk:wght@400;500;600&display=swap"
|
|
11
|
+
rel="stylesheet"
|
|
12
|
+
/>
|
|
13
|
+
<link
|
|
14
|
+
rel="stylesheet"
|
|
15
|
+
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css"
|
|
16
|
+
/>
|
|
17
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div class="page">
|
|
21
|
+
<header class="hero">
|
|
22
|
+
<div>
|
|
23
|
+
<div class="eyebrow">Breakpoint Manager</div>
|
|
24
|
+
<h1>Release agents with confidence.</h1>
|
|
25
|
+
<p class="subtitle">
|
|
26
|
+
Review waiting breakpoints, leave feedback, and unblock workflows.
|
|
27
|
+
</p>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="token-panel">
|
|
30
|
+
<label for="token">Human Token</label>
|
|
31
|
+
<input id="token" type="password" placeholder="Bearer token" />
|
|
32
|
+
<button id="save-token">Save</button>
|
|
33
|
+
<p class="hint">Token required for list and feedback endpoints.</p>
|
|
34
|
+
</div>
|
|
35
|
+
</header>
|
|
36
|
+
|
|
37
|
+
<section class="controls">
|
|
38
|
+
<div class="field">
|
|
39
|
+
<label for="status">Status</label>
|
|
40
|
+
<select id="status">
|
|
41
|
+
<option value="">Any</option>
|
|
42
|
+
<option value="waiting" selected>Waiting</option>
|
|
43
|
+
<option value="released">Released</option>
|
|
44
|
+
<option value="expired">Expired</option>
|
|
45
|
+
<option value="cancelled">Cancelled</option>
|
|
46
|
+
</select>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="field">
|
|
49
|
+
<label for="tag">Tag</label>
|
|
50
|
+
<input id="tag" type="text" placeholder="e.g. deploy" />
|
|
51
|
+
</div>
|
|
52
|
+
<div class="field">
|
|
53
|
+
<label for="agentId">Agent</label>
|
|
54
|
+
<input id="agentId" type="text" placeholder="agent-123" />
|
|
55
|
+
</div>
|
|
56
|
+
<div class="field">
|
|
57
|
+
<label for="auto-refresh">Auto-refresh</label>
|
|
58
|
+
<select id="auto-refresh">
|
|
59
|
+
<option value="off">Off</option>
|
|
60
|
+
<option value="5">Every 5s</option>
|
|
61
|
+
<option value="10" selected>Every 10s</option>
|
|
62
|
+
<option value="30">Every 30s</option>
|
|
63
|
+
</select>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="field">
|
|
66
|
+
<label for="notify">Desktop alerts</label>
|
|
67
|
+
<select id="notify">
|
|
68
|
+
<option value="off" selected>Off</option>
|
|
69
|
+
<option value="on">On</option>
|
|
70
|
+
</select>
|
|
71
|
+
<div id="notify-status" class="hint"></div>
|
|
72
|
+
</div>
|
|
73
|
+
<button id="refresh">Refresh</button>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section class="stats">
|
|
77
|
+
<div class="stat-card">
|
|
78
|
+
<div class="stat-label">Waiting</div>
|
|
79
|
+
<div id="stat-waiting" class="stat-value">0</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="stat-card">
|
|
82
|
+
<div class="stat-label">Released</div>
|
|
83
|
+
<div id="stat-released" class="stat-value">0</div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="stat-card">
|
|
86
|
+
<div class="stat-label">Expired</div>
|
|
87
|
+
<div id="stat-expired" class="stat-value">0</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="stat-card">
|
|
90
|
+
<div class="stat-label">Cancelled</div>
|
|
91
|
+
<div id="stat-cancelled" class="stat-value">0</div>
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
|
|
95
|
+
<main class="layout">
|
|
96
|
+
<section class="list">
|
|
97
|
+
<div class="section-title">Waiting Breakpoints</div>
|
|
98
|
+
<div id="list-message" class="hint"></div>
|
|
99
|
+
<ul id="breakpoint-list"></ul>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="detail">
|
|
103
|
+
<div class="section-title">Details</div>
|
|
104
|
+
<div id="detail-empty">Select a breakpoint to review.</div>
|
|
105
|
+
<div id="detail-view" class="hidden">
|
|
106
|
+
<div class="detail-header">
|
|
107
|
+
<h2 id="detail-title"></h2>
|
|
108
|
+
<span id="detail-status" class="status-pill"></span>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="detail-meta" id="detail-meta"></div>
|
|
111
|
+
<pre id="detail-payload"></pre>
|
|
112
|
+
|
|
113
|
+
<div class="context">
|
|
114
|
+
<h3>Context Files</h3>
|
|
115
|
+
<div id="context-empty" class="hint">No context files provided.</div>
|
|
116
|
+
<div class="context-layout">
|
|
117
|
+
<ul id="context-list"></ul>
|
|
118
|
+
<div class="context-viewer">
|
|
119
|
+
<div id="context-meta" class="hint"></div>
|
|
120
|
+
<div id="context-render" class="context-render"></div>
|
|
121
|
+
<pre id="context-code" class="hidden"><code id="context-code-inner"></code></pre>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div class="feedback">
|
|
127
|
+
<h3>Feedback</h3>
|
|
128
|
+
<ul id="feedback-list"></ul>
|
|
129
|
+
<textarea id="feedback-text" rows="4" placeholder="Leave a comment..."></textarea>
|
|
130
|
+
<div class="feedback-actions">
|
|
131
|
+
<input id="feedback-author" type="text" placeholder="Your name" />
|
|
132
|
+
<button id="send-feedback">Comment</button>
|
|
133
|
+
<button id="release">Release</button>
|
|
134
|
+
</div>
|
|
135
|
+
<div id="feedback-message" class="hint"></div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</section>
|
|
139
|
+
</main>
|
|
140
|
+
|
|
141
|
+
<section class="extensions">
|
|
142
|
+
<div class="section-title">Extensions</div>
|
|
143
|
+
<div id="extensions-message" class="hint"></div>
|
|
144
|
+
<div id="extensions-list"></div>
|
|
145
|
+
</section>
|
|
146
|
+
</div>
|
|
147
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
|
|
148
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
149
|
+
<script src="./app.js"></script>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
package/web/server.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const express = require("express");
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
const port = process.env.WEB_PORT || 3184;
|
|
8
|
+
|
|
9
|
+
app.use("/", express.static(path.join(__dirname)));
|
|
10
|
+
|
|
11
|
+
app.listen(port, () => {
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.log(`Web UI listening on http://localhost:${port}`);
|
|
14
|
+
});
|