@prads01/blackbox 0.1.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 +48 -0
- package/dist/api.js +65 -0
- package/dist/commands/analyze.js +30 -0
- package/dist/commands/init.js +11 -0
- package/dist/commands/setup.js +148 -0
- package/dist/commands/status.js +23 -0
- package/dist/commands/watch.js +64 -0
- package/dist/config.js +51 -0
- package/dist/files.js +20 -0
- package/dist/index.js +71 -0
- package/dist/report.js +36 -0
- package/dist/tui/app.js +408 -0
- package/dist/tui/data.js +150 -0
- package/dist/tui/keybindings.js +122 -0
- package/dist/tui/render.js +292 -0
- package/dist/tui/types.js +1 -0
- package/dist/tui/watch-service.js +73 -0
- package/dist/types.js +1 -0
- package/dist/utils/format.js +18 -0
- package/package.json +38 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import blessed from "blessed";
|
|
2
|
+
import { fetchApiReachability, fetchIncidents, loadTuiConfig, triggerCreatePr, triggerAnalysis, triggerSandboxFix, updateIncidentStatus, } from "./data.js";
|
|
3
|
+
import { attachKeybindings } from "./keybindings.js";
|
|
4
|
+
import { indexToView, palette, renderFooter, renderMain, renderSidebar, viewToIndex, } from "./render.js";
|
|
5
|
+
import { TuiWatchService } from "./watch-service.js";
|
|
6
|
+
let crashHandlersInstalled = false;
|
|
7
|
+
function installCrashHandlers() {
|
|
8
|
+
if (crashHandlersInstalled)
|
|
9
|
+
return;
|
|
10
|
+
crashHandlersInstalled = true;
|
|
11
|
+
process.on("uncaughtException", (error) => {
|
|
12
|
+
console.error("[blackbox:tui] uncaughtException:", error);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
|
15
|
+
process.on("unhandledRejection", (reason) => {
|
|
16
|
+
console.error("[blackbox:tui] unhandledRejection:", reason);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function createInitialState(config) {
|
|
21
|
+
return {
|
|
22
|
+
view: "dashboard",
|
|
23
|
+
focusZone: "nav",
|
|
24
|
+
selectedNavIndex: 0,
|
|
25
|
+
config,
|
|
26
|
+
apiReachable: false,
|
|
27
|
+
incidents: [],
|
|
28
|
+
selectedIncidentIndex: 0,
|
|
29
|
+
incidentsMode: "list",
|
|
30
|
+
sandboxMode: "list",
|
|
31
|
+
selectedSandboxIndex: 0,
|
|
32
|
+
lastSandboxIncidentId: null,
|
|
33
|
+
watchActive: false,
|
|
34
|
+
watchLogs: [],
|
|
35
|
+
lastAnalysisAt: null,
|
|
36
|
+
lastSandboxResult: null,
|
|
37
|
+
sandboxResultsByIncidentId: {},
|
|
38
|
+
sandboxPrResult: null,
|
|
39
|
+
sandboxPrError: null,
|
|
40
|
+
sandboxPrInFlight: false,
|
|
41
|
+
analyzeInFlight: false,
|
|
42
|
+
busyMessage: null,
|
|
43
|
+
statusMessage: null,
|
|
44
|
+
errorMessage: null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function getSandboxReadyIncidents(state) {
|
|
48
|
+
return state.incidents.filter((incident) => {
|
|
49
|
+
const result = state.sandboxResultsByIncidentId[incident.id];
|
|
50
|
+
return !result || result.status !== "success";
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function hexToRgb(hex) {
|
|
54
|
+
const normalized = hex.replace("#", "");
|
|
55
|
+
const bigint = Number.parseInt(normalized, 16);
|
|
56
|
+
const r = (bigint >> 16) & 255;
|
|
57
|
+
const g = (bigint >> 8) & 255;
|
|
58
|
+
const b = bigint & 255;
|
|
59
|
+
return [r, g, b];
|
|
60
|
+
}
|
|
61
|
+
function rgbToHex(r, g, b) {
|
|
62
|
+
const toHex = (value) => value.toString(16).padStart(2, "0");
|
|
63
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
64
|
+
}
|
|
65
|
+
function blendTowardsWhite(hex, amount) {
|
|
66
|
+
const [r, g, b] = hexToRgb(hex);
|
|
67
|
+
const clamped = Math.max(0, Math.min(1, amount));
|
|
68
|
+
const nr = Math.round(r + (255 - r) * clamped);
|
|
69
|
+
const ng = Math.round(g + (255 - g) * clamped);
|
|
70
|
+
const nb = Math.round(b + (255 - b) * clamped);
|
|
71
|
+
return rgbToHex(nr, ng, nb);
|
|
72
|
+
}
|
|
73
|
+
export async function runTui() {
|
|
74
|
+
installCrashHandlers();
|
|
75
|
+
const config = await loadTuiConfig();
|
|
76
|
+
const state = createInitialState(config);
|
|
77
|
+
const screen = blessed.screen({
|
|
78
|
+
smartCSR: true,
|
|
79
|
+
title: "BlackBox TUI",
|
|
80
|
+
fullUnicode: true,
|
|
81
|
+
});
|
|
82
|
+
const header = blessed.box({
|
|
83
|
+
parent: screen,
|
|
84
|
+
top: 0,
|
|
85
|
+
left: 0,
|
|
86
|
+
width: "100%",
|
|
87
|
+
height: 10,
|
|
88
|
+
tags: true,
|
|
89
|
+
border: "line",
|
|
90
|
+
padding: { left: 1, right: 1, top: 0, bottom: 0 },
|
|
91
|
+
style: {
|
|
92
|
+
fg: "white",
|
|
93
|
+
border: { fg: palette.accent },
|
|
94
|
+
},
|
|
95
|
+
content: (() => {
|
|
96
|
+
const bannerLines = [
|
|
97
|
+
"██████╗ ██╗ █████╗ ██████╗██╗ ██╗██████╗ ██████╗ ██╗ ██╗",
|
|
98
|
+
"██╔══██╗██║ ██╔══██╗██╔════╝██║ ██╔╝██╔══██╗██╔═══██╗╚██╗██╔╝",
|
|
99
|
+
"██████╔╝██║ ███████║██║ █████╔╝ ██████╔╝██║ ██║ ╚███╔╝ ",
|
|
100
|
+
"██╔══██╗██║ ██╔══██║██║ ██╔═██╗ ██╔══██╗██║ ██║ ██╔██╗ ",
|
|
101
|
+
"██████╔╝███████╗██║ ██║╚██████╗██║ ██╗██████╔╝╚██████╔╝██╔╝ ██╗",
|
|
102
|
+
"╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝",
|
|
103
|
+
];
|
|
104
|
+
const steps = [0, 0.18, 0.35, 0.55, 0.78, 0.92];
|
|
105
|
+
const gradientLines = bannerLines.map((line, index) => {
|
|
106
|
+
const color = blendTowardsWhite(palette.accentHex, steps[index] ?? 1);
|
|
107
|
+
return `{bold}{${color}-fg}${line}{/${color}-fg}{/bold}`;
|
|
108
|
+
});
|
|
109
|
+
gradientLines.push("{gray-fg}Production Incident Forensics{/gray-fg}");
|
|
110
|
+
return gradientLines.join("\n");
|
|
111
|
+
})(),
|
|
112
|
+
});
|
|
113
|
+
const sidebar = blessed.list({
|
|
114
|
+
parent: screen,
|
|
115
|
+
top: 10,
|
|
116
|
+
left: 0,
|
|
117
|
+
width: 24,
|
|
118
|
+
height: "100%-13",
|
|
119
|
+
tags: true,
|
|
120
|
+
keys: true,
|
|
121
|
+
vi: true,
|
|
122
|
+
mouse: true,
|
|
123
|
+
border: "line",
|
|
124
|
+
label: " Navigation ",
|
|
125
|
+
style: {
|
|
126
|
+
fg: "white",
|
|
127
|
+
border: { fg: palette.borderDefault },
|
|
128
|
+
selected: { fg: "white", bg: palette.accent },
|
|
129
|
+
item: { fg: "white" },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const main = blessed.box({
|
|
133
|
+
parent: screen,
|
|
134
|
+
top: 10,
|
|
135
|
+
left: 24,
|
|
136
|
+
width: "100%-24",
|
|
137
|
+
height: "100%-13",
|
|
138
|
+
tags: true,
|
|
139
|
+
border: "line",
|
|
140
|
+
label: " View ",
|
|
141
|
+
scrollable: true,
|
|
142
|
+
alwaysScroll: true,
|
|
143
|
+
keys: true,
|
|
144
|
+
mouse: true,
|
|
145
|
+
padding: { left: 1, right: 1, top: 1, bottom: 1 },
|
|
146
|
+
style: {
|
|
147
|
+
fg: "white",
|
|
148
|
+
border: { fg: palette.borderDefault },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const footer = blessed.box({
|
|
152
|
+
parent: screen,
|
|
153
|
+
bottom: 0,
|
|
154
|
+
left: 0,
|
|
155
|
+
width: "100%",
|
|
156
|
+
height: 3,
|
|
157
|
+
tags: true,
|
|
158
|
+
border: "line",
|
|
159
|
+
style: {
|
|
160
|
+
fg: "white",
|
|
161
|
+
border: { fg: palette.accent },
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
let refreshTimer = null;
|
|
165
|
+
let statusTimer = null;
|
|
166
|
+
let closed = false;
|
|
167
|
+
let resolveClose = null;
|
|
168
|
+
let lastAnalyzeStartedAt = 0;
|
|
169
|
+
const render = () => {
|
|
170
|
+
sidebar.setLabel(state.focusZone === "nav" ? " Navigation (Focused) " : " Navigation ");
|
|
171
|
+
main.setLabel(state.focusZone === "view" ? " View (Focused) " : " View ");
|
|
172
|
+
sidebar.style.border.fg = state.focusZone === "nav" ? palette.accent : palette.borderDefault;
|
|
173
|
+
main.style.border.fg = state.focusZone === "view" ? palette.accent : palette.borderDefault;
|
|
174
|
+
renderSidebar(sidebar, state);
|
|
175
|
+
sidebar.select(state.selectedNavIndex);
|
|
176
|
+
renderMain(main, state);
|
|
177
|
+
renderFooter(footer, state);
|
|
178
|
+
screen.render();
|
|
179
|
+
};
|
|
180
|
+
const withBusy = async (message, action) => {
|
|
181
|
+
state.busyMessage = message;
|
|
182
|
+
state.statusMessage = null;
|
|
183
|
+
state.errorMessage = null;
|
|
184
|
+
render();
|
|
185
|
+
try {
|
|
186
|
+
await action();
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
state.errorMessage = error instanceof Error ? error.message : String(error);
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
state.busyMessage = null;
|
|
193
|
+
render();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const refreshData = async () => {
|
|
197
|
+
try {
|
|
198
|
+
state.apiReachable = await fetchApiReachability(config);
|
|
199
|
+
state.incidents = await fetchIncidents(config);
|
|
200
|
+
if (state.selectedIncidentIndex > state.incidents.length - 1) {
|
|
201
|
+
state.selectedIncidentIndex = Math.max(0, state.incidents.length - 1);
|
|
202
|
+
}
|
|
203
|
+
const ready = getSandboxReadyIncidents(state);
|
|
204
|
+
if (state.selectedSandboxIndex > ready.length - 1) {
|
|
205
|
+
state.selectedSandboxIndex = Math.max(0, ready.length - 1);
|
|
206
|
+
}
|
|
207
|
+
state.errorMessage = null;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
state.errorMessage = error instanceof Error ? error.message : String(error);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const runAnalyzeOnce = async () => {
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
if (state.analyzeInFlight)
|
|
216
|
+
return;
|
|
217
|
+
if (now - lastAnalyzeStartedAt < 1200)
|
|
218
|
+
return;
|
|
219
|
+
lastAnalyzeStartedAt = now;
|
|
220
|
+
state.analyzeInFlight = true;
|
|
221
|
+
let didSucceed = false;
|
|
222
|
+
await withBusy("Analyzing...", async () => {
|
|
223
|
+
await triggerAnalysis(config);
|
|
224
|
+
state.lastAnalysisAt = new Date().toISOString();
|
|
225
|
+
await refreshData();
|
|
226
|
+
state.view = "dashboard";
|
|
227
|
+
state.selectedNavIndex = viewToIndex("dashboard");
|
|
228
|
+
didSucceed = true;
|
|
229
|
+
});
|
|
230
|
+
state.analyzeInFlight = false;
|
|
231
|
+
if (didSucceed) {
|
|
232
|
+
state.statusMessage = "Analysis complete";
|
|
233
|
+
if (statusTimer)
|
|
234
|
+
clearTimeout(statusTimer);
|
|
235
|
+
statusTimer = setTimeout(() => {
|
|
236
|
+
state.statusMessage = null;
|
|
237
|
+
render();
|
|
238
|
+
}, 2000);
|
|
239
|
+
}
|
|
240
|
+
render();
|
|
241
|
+
};
|
|
242
|
+
const markIncidentStatus = async (status) => {
|
|
243
|
+
if (state.view !== "incidents")
|
|
244
|
+
return;
|
|
245
|
+
const selectedIncident = state.incidents[state.selectedIncidentIndex];
|
|
246
|
+
if (!selectedIncident)
|
|
247
|
+
return;
|
|
248
|
+
await withBusy(`Marking ${status}...`, async () => {
|
|
249
|
+
await updateIncidentStatus(config, selectedIncident.id, status);
|
|
250
|
+
await refreshData();
|
|
251
|
+
state.statusMessage =
|
|
252
|
+
status === "fixed" ? "Incident marked fixed" : "Incident marked investigating";
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const watchService = new TuiWatchService(config, {
|
|
256
|
+
onLog: (line) => {
|
|
257
|
+
state.watchLogs.push(line);
|
|
258
|
+
if (state.watchLogs.length > 100)
|
|
259
|
+
state.watchLogs = state.watchLogs.slice(-100);
|
|
260
|
+
render();
|
|
261
|
+
},
|
|
262
|
+
onChangeAnalyze: async () => {
|
|
263
|
+
if (state.analyzeInFlight)
|
|
264
|
+
return;
|
|
265
|
+
await runAnalyzeOnce();
|
|
266
|
+
render();
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
const closeApp = async () => {
|
|
270
|
+
if (closed)
|
|
271
|
+
return;
|
|
272
|
+
closed = true;
|
|
273
|
+
if (refreshTimer) {
|
|
274
|
+
clearInterval(refreshTimer);
|
|
275
|
+
refreshTimer = null;
|
|
276
|
+
}
|
|
277
|
+
if (statusTimer) {
|
|
278
|
+
clearTimeout(statusTimer);
|
|
279
|
+
statusTimer = null;
|
|
280
|
+
}
|
|
281
|
+
await watchService.stop();
|
|
282
|
+
try {
|
|
283
|
+
screen.destroy();
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// no-op
|
|
287
|
+
}
|
|
288
|
+
if (resolveClose)
|
|
289
|
+
resolveClose();
|
|
290
|
+
};
|
|
291
|
+
attachKeybindings(screen, state, {
|
|
292
|
+
render,
|
|
293
|
+
quit: closeApp,
|
|
294
|
+
refresh: async () => {
|
|
295
|
+
await withBusy("Refreshing status...", async () => {
|
|
296
|
+
await refreshData();
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
runAnalyze: runAnalyzeOnce,
|
|
300
|
+
toggleWatch: async () => {
|
|
301
|
+
await withBusy(state.watchActive ? "Stopping watch..." : "Starting watch...", async () => {
|
|
302
|
+
if (state.watchActive) {
|
|
303
|
+
await watchService.stop();
|
|
304
|
+
state.watchActive = false;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await watchService.start();
|
|
308
|
+
state.watchActive = true;
|
|
309
|
+
}
|
|
310
|
+
state.view = "watch";
|
|
311
|
+
state.selectedNavIndex = viewToIndex("watch");
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
runSandboxFix: async () => {
|
|
315
|
+
await withBusy("Running sandbox fix...", async () => {
|
|
316
|
+
const ready = getSandboxReadyIncidents(state);
|
|
317
|
+
const selected = ready[state.selectedSandboxIndex];
|
|
318
|
+
if (!selected) {
|
|
319
|
+
throw new Error("No sandbox-ready incident available.");
|
|
320
|
+
}
|
|
321
|
+
const result = await triggerSandboxFix(config, selected.id);
|
|
322
|
+
await refreshData();
|
|
323
|
+
state.lastSandboxIncidentId = selected.id;
|
|
324
|
+
state.lastSandboxResult = result;
|
|
325
|
+
state.sandboxResultsByIncidentId[selected.id] = result;
|
|
326
|
+
state.sandboxPrResult = null;
|
|
327
|
+
state.sandboxPrError = null;
|
|
328
|
+
state.sandboxPrInFlight = false;
|
|
329
|
+
if (result.status === "success") {
|
|
330
|
+
const nextReady = getSandboxReadyIncidents(state);
|
|
331
|
+
if (state.selectedSandboxIndex > nextReady.length - 1) {
|
|
332
|
+
state.selectedSandboxIndex = Math.max(0, nextReady.length - 1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
state.view = "sandbox";
|
|
336
|
+
state.sandboxMode = "result";
|
|
337
|
+
state.selectedNavIndex = viewToIndex("sandbox");
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
createPr: async () => {
|
|
341
|
+
const last = state.lastSandboxResult;
|
|
342
|
+
if (!last)
|
|
343
|
+
return;
|
|
344
|
+
if (!last.verification.passed)
|
|
345
|
+
return;
|
|
346
|
+
if (!state.lastSandboxIncidentId)
|
|
347
|
+
return;
|
|
348
|
+
if (!last.workspacePath.trim())
|
|
349
|
+
return;
|
|
350
|
+
state.sandboxPrInFlight = true;
|
|
351
|
+
state.sandboxPrError = null;
|
|
352
|
+
state.sandboxPrResult = null;
|
|
353
|
+
await withBusy("Creating PR...", async () => {
|
|
354
|
+
try {
|
|
355
|
+
const result = await triggerCreatePr(config, state.lastSandboxIncidentId, last.workspacePath);
|
|
356
|
+
await refreshData();
|
|
357
|
+
state.sandboxPrResult = result;
|
|
358
|
+
if (result.status === "success") {
|
|
359
|
+
state.statusMessage = "PR created successfully";
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
state.sandboxPrError = result.summary;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
state.sandboxPrError = error instanceof Error ? error.message : String(error);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
state.sandboxPrInFlight = false;
|
|
370
|
+
render();
|
|
371
|
+
},
|
|
372
|
+
markIncidentFixed: async () => {
|
|
373
|
+
await markIncidentStatus("fixed");
|
|
374
|
+
},
|
|
375
|
+
markIncidentInvestigating: async () => {
|
|
376
|
+
await markIncidentStatus("investigating");
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
sidebar.on("select", (_item, index) => {
|
|
380
|
+
state.selectedNavIndex = index;
|
|
381
|
+
if (state.focusZone === "nav") {
|
|
382
|
+
state.view = indexToView(index);
|
|
383
|
+
if (state.view === "sandbox")
|
|
384
|
+
state.sandboxMode = "list";
|
|
385
|
+
}
|
|
386
|
+
render();
|
|
387
|
+
});
|
|
388
|
+
await withBusy("Loading data...", async () => {
|
|
389
|
+
await refreshData();
|
|
390
|
+
});
|
|
391
|
+
refreshTimer = setInterval(() => {
|
|
392
|
+
void refreshData().then(() => render());
|
|
393
|
+
}, 10000);
|
|
394
|
+
screen.on("destroy", () => {
|
|
395
|
+
if (refreshTimer) {
|
|
396
|
+
clearInterval(refreshTimer);
|
|
397
|
+
refreshTimer = null;
|
|
398
|
+
}
|
|
399
|
+
if (!closed && resolveClose) {
|
|
400
|
+
closed = true;
|
|
401
|
+
resolveClose();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
render();
|
|
405
|
+
await new Promise((resolve) => {
|
|
406
|
+
resolveClose = resolve;
|
|
407
|
+
});
|
|
408
|
+
}
|
package/dist/tui/data.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { isApiReachable } from "../api.js";
|
|
2
|
+
import { readConfig } from "../config.js";
|
|
3
|
+
import { loadEvidenceFromConfig } from "../files.js";
|
|
4
|
+
function assertIncidentsResponse(value) {
|
|
5
|
+
if (!value || typeof value !== "object")
|
|
6
|
+
return false;
|
|
7
|
+
const candidate = value;
|
|
8
|
+
if (!Array.isArray(candidate.incidents))
|
|
9
|
+
return false;
|
|
10
|
+
return candidate.incidents.every((item) => {
|
|
11
|
+
if (!item || typeof item !== "object")
|
|
12
|
+
return false;
|
|
13
|
+
const i = item;
|
|
14
|
+
return (typeof i.id === "string" &&
|
|
15
|
+
typeof i.incidentTitle === "string" &&
|
|
16
|
+
typeof i.severity === "string" &&
|
|
17
|
+
(i.status === undefined ||
|
|
18
|
+
i.status === "new" ||
|
|
19
|
+
i.status === "investigating" ||
|
|
20
|
+
i.status === "sandbox_verified" ||
|
|
21
|
+
i.status === "pr_opened" ||
|
|
22
|
+
i.status === "fixed") &&
|
|
23
|
+
typeof i.topContradiction === "string" &&
|
|
24
|
+
typeof i.rootCause === "string" &&
|
|
25
|
+
typeof i.impact === "string" &&
|
|
26
|
+
typeof i.nextAction === "string" &&
|
|
27
|
+
(i.evidencePoints === undefined || Array.isArray(i.evidencePoints)) &&
|
|
28
|
+
typeof i.timestamp === "string");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function loadTuiConfig() {
|
|
32
|
+
return readConfig();
|
|
33
|
+
}
|
|
34
|
+
export async function fetchApiReachability(config) {
|
|
35
|
+
return isApiReachable(config);
|
|
36
|
+
}
|
|
37
|
+
export async function fetchIncidents(config) {
|
|
38
|
+
const response = await fetch(`${config.api_base_url}/api/incidents`, {
|
|
39
|
+
method: "GET",
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(`Incidents request failed with status ${response.status}`);
|
|
43
|
+
}
|
|
44
|
+
const payload = (await response.json());
|
|
45
|
+
if (!assertIncidentsResponse(payload)) {
|
|
46
|
+
throw new Error("Invalid incidents response shape");
|
|
47
|
+
}
|
|
48
|
+
return payload.incidents.map((incident) => ({
|
|
49
|
+
...incident,
|
|
50
|
+
status: incident.status ?? "new",
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
export async function triggerAnalysis(config) {
|
|
54
|
+
const evidence = await loadEvidenceFromConfig(config);
|
|
55
|
+
const payload = {
|
|
56
|
+
project: {
|
|
57
|
+
name: config.project_name,
|
|
58
|
+
environment: config.environment,
|
|
59
|
+
},
|
|
60
|
+
evidence,
|
|
61
|
+
};
|
|
62
|
+
const response = await fetch(`${config.api_base_url}/api/analyze`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(payload),
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const body = await response.text();
|
|
71
|
+
throw new Error(`Analyze failed (${response.status}): ${body}`);
|
|
72
|
+
}
|
|
73
|
+
const data = (await response.json());
|
|
74
|
+
if (!data || typeof data !== "object") {
|
|
75
|
+
throw new Error("Analyze response was invalid.");
|
|
76
|
+
}
|
|
77
|
+
const result = data.result;
|
|
78
|
+
if (!result || typeof result !== "object") {
|
|
79
|
+
throw new Error("Analyze response missing result.");
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
function assertSandboxResult(value) {
|
|
84
|
+
if (!value || typeof value !== "object")
|
|
85
|
+
return false;
|
|
86
|
+
const candidate = value;
|
|
87
|
+
return ((candidate.status === "success" || candidate.status === "failed") &&
|
|
88
|
+
typeof candidate.workspacePath === "string" &&
|
|
89
|
+
typeof candidate.patchSummary === "string" &&
|
|
90
|
+
Array.isArray(candidate.filesChanged) &&
|
|
91
|
+
!!candidate.verification &&
|
|
92
|
+
!!candidate.prPreview);
|
|
93
|
+
}
|
|
94
|
+
function assertCreatePrResult(value) {
|
|
95
|
+
if (!value || typeof value !== "object")
|
|
96
|
+
return false;
|
|
97
|
+
const candidate = value;
|
|
98
|
+
return ((candidate.status === "success" || candidate.status === "failed") &&
|
|
99
|
+
typeof candidate.branchName === "string" &&
|
|
100
|
+
typeof candidate.commitSha === "string" &&
|
|
101
|
+
typeof candidate.prUrl === "string" &&
|
|
102
|
+
(candidate.prNumber === null || typeof candidate.prNumber === "number") &&
|
|
103
|
+
typeof candidate.summary === "string");
|
|
104
|
+
}
|
|
105
|
+
export async function triggerSandboxFix(config, incidentId) {
|
|
106
|
+
const response = await fetch(`${config.api_base_url}/api/fix-runner`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({ incidentId }),
|
|
112
|
+
});
|
|
113
|
+
const payload = (await response.json());
|
|
114
|
+
if (!assertSandboxResult(payload)) {
|
|
115
|
+
throw new Error("Sandbox runner returned invalid payload.");
|
|
116
|
+
}
|
|
117
|
+
return payload;
|
|
118
|
+
}
|
|
119
|
+
export async function triggerCreatePr(config, incidentId, workspacePath) {
|
|
120
|
+
const response = await fetch(`${config.api_base_url}/api/create-pr`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({ incidentId, workspacePath }),
|
|
126
|
+
});
|
|
127
|
+
const payload = (await response.json());
|
|
128
|
+
if (!assertCreatePrResult(payload)) {
|
|
129
|
+
throw new Error("Create PR endpoint returned invalid payload.");
|
|
130
|
+
}
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
|
133
|
+
export async function updateIncidentStatus(config, incidentId, status) {
|
|
134
|
+
const response = await fetch(`${config.api_base_url}/api/incidents/${incidentId}/status`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: {
|
|
137
|
+
"Content-Type": "application/json",
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ status }),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const body = await response.text();
|
|
143
|
+
throw new Error(`Status update failed (${response.status}): ${body}`);
|
|
144
|
+
}
|
|
145
|
+
const payload = (await response.json());
|
|
146
|
+
if (!payload.incident) {
|
|
147
|
+
throw new Error("Status update response missing incident.");
|
|
148
|
+
}
|
|
149
|
+
return payload.incident;
|
|
150
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { indexToView, NAV_ITEMS } from "./render.js";
|
|
2
|
+
function moveInView(state, delta) {
|
|
3
|
+
if (state.view === "incidents" && state.incidentsMode === "list") {
|
|
4
|
+
state.selectedIncidentIndex = Math.max(0, Math.min(state.incidents.length - 1, state.selectedIncidentIndex + delta));
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
if (state.view === "sandbox" && state.sandboxMode === "list") {
|
|
8
|
+
const unresolvedCount = state.incidents.filter((incident) => {
|
|
9
|
+
const result = state.sandboxResultsByIncidentId[incident.id];
|
|
10
|
+
return !result || result.status !== "success";
|
|
11
|
+
}).length;
|
|
12
|
+
state.selectedSandboxIndex = Math.max(0, Math.min(Math.max(0, unresolvedCount - 1), state.selectedSandboxIndex + delta));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function attachKeybindings(screen, state, actions) {
|
|
16
|
+
screen.key(["C-c", "q", "escape"], () => {
|
|
17
|
+
void actions.quit();
|
|
18
|
+
});
|
|
19
|
+
screen.key(["left"], () => {
|
|
20
|
+
state.focusZone = "nav";
|
|
21
|
+
actions.render();
|
|
22
|
+
});
|
|
23
|
+
screen.key(["right"], () => {
|
|
24
|
+
state.focusZone = "view";
|
|
25
|
+
actions.render();
|
|
26
|
+
});
|
|
27
|
+
screen.key(["up", "k"], () => {
|
|
28
|
+
if (state.focusZone === "nav") {
|
|
29
|
+
state.selectedNavIndex = Math.max(0, state.selectedNavIndex - 1);
|
|
30
|
+
actions.render();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
moveInView(state, -1);
|
|
34
|
+
actions.render();
|
|
35
|
+
});
|
|
36
|
+
screen.key(["down", "j"], () => {
|
|
37
|
+
if (state.focusZone === "nav") {
|
|
38
|
+
state.selectedNavIndex = Math.min(NAV_ITEMS.length - 1, state.selectedNavIndex + 1);
|
|
39
|
+
actions.render();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
moveInView(state, 1);
|
|
43
|
+
actions.render();
|
|
44
|
+
});
|
|
45
|
+
screen.key(["enter"], () => {
|
|
46
|
+
if (state.focusZone === "nav") {
|
|
47
|
+
state.view = indexToView(state.selectedNavIndex);
|
|
48
|
+
if (state.view === "incidents") {
|
|
49
|
+
state.incidentsMode = "list";
|
|
50
|
+
}
|
|
51
|
+
if (state.view === "sandbox") {
|
|
52
|
+
state.sandboxMode = "list";
|
|
53
|
+
}
|
|
54
|
+
actions.render();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (state.view === "incidents" && state.incidentsMode === "list" && state.incidents.length > 0) {
|
|
58
|
+
state.incidentsMode = "detail";
|
|
59
|
+
actions.render();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (state.view === "sandbox" && state.sandboxMode === "list") {
|
|
63
|
+
void actions.runSandboxFix();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
screen.key(["backspace"], () => {
|
|
67
|
+
if (state.view === "incidents" && state.incidentsMode === "detail") {
|
|
68
|
+
state.incidentsMode = "list";
|
|
69
|
+
actions.render();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (state.view === "sandbox" && state.sandboxMode === "result") {
|
|
73
|
+
state.sandboxMode = "list";
|
|
74
|
+
actions.render();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
screen.key(["d"], () => {
|
|
78
|
+
state.view = "dashboard";
|
|
79
|
+
state.selectedNavIndex = 0;
|
|
80
|
+
actions.render();
|
|
81
|
+
});
|
|
82
|
+
screen.key(["i"], () => {
|
|
83
|
+
state.view = "incidents";
|
|
84
|
+
state.incidentsMode = "list";
|
|
85
|
+
state.selectedNavIndex = 1;
|
|
86
|
+
actions.render();
|
|
87
|
+
});
|
|
88
|
+
screen.key(["s"], () => {
|
|
89
|
+
state.view = "sandbox";
|
|
90
|
+
state.sandboxMode = "list";
|
|
91
|
+
state.selectedNavIndex = 4;
|
|
92
|
+
actions.render();
|
|
93
|
+
});
|
|
94
|
+
screen.key(["r"], () => {
|
|
95
|
+
void actions.refresh();
|
|
96
|
+
});
|
|
97
|
+
screen.key(["a"], () => {
|
|
98
|
+
void actions.runAnalyze();
|
|
99
|
+
});
|
|
100
|
+
screen.key(["w"], () => {
|
|
101
|
+
void actions.toggleWatch();
|
|
102
|
+
});
|
|
103
|
+
screen.key(["x"], () => {
|
|
104
|
+
void actions.runSandboxFix();
|
|
105
|
+
});
|
|
106
|
+
screen.key(["c"], () => {
|
|
107
|
+
const canCreatePr = state.view === "sandbox" &&
|
|
108
|
+
state.sandboxMode === "result" &&
|
|
109
|
+
!!state.lastSandboxResult &&
|
|
110
|
+
state.lastSandboxResult.verification.passed &&
|
|
111
|
+
state.lastSandboxResult.workspacePath.trim().length > 0;
|
|
112
|
+
if (!canCreatePr)
|
|
113
|
+
return;
|
|
114
|
+
void actions.createPr();
|
|
115
|
+
});
|
|
116
|
+
screen.key(["f"], () => {
|
|
117
|
+
void actions.markIncidentFixed();
|
|
118
|
+
});
|
|
119
|
+
screen.key(["v"], () => {
|
|
120
|
+
void actions.markIncidentInvestigating();
|
|
121
|
+
});
|
|
122
|
+
}
|