bosun 0.31.4 → 0.31.5
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/error-detector.mjs +17 -0
- package/package.json +1 -1
- package/task-executor.mjs +26 -11
- package/ui/app.js +114 -1
- package/ui/modules/api.js +54 -22
- package/ui/styles/components.css +164 -0
- package/ui/tabs/dashboard.js +103 -2
- package/utils.mjs +60 -0
package/error-detector.mjs
CHANGED
|
@@ -240,6 +240,22 @@ export const PATTERN_SEVERITY = {
|
|
|
240
240
|
unknown: "low",
|
|
241
241
|
};
|
|
242
242
|
|
|
243
|
+
// ── Remediation hints ──────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Human-readable remediation hints for each error type.
|
|
247
|
+
* Surfaced in UI and Telegram notifications to guide users.
|
|
248
|
+
*/
|
|
249
|
+
const REMEDIATION_HINTS = {
|
|
250
|
+
rate_limit: "Wait a few minutes before retrying. Consider reducing MAX_PARALLEL.",
|
|
251
|
+
oom: "Reduce MAX_PARALLEL or increase available memory. Use --max-old-space-size.",
|
|
252
|
+
oom_kill: "Process was killed by the OS. Reduce memory usage or increase system RAM.",
|
|
253
|
+
git_conflict: "Manual conflict resolution required. Run: git mergetool",
|
|
254
|
+
push_failure: "Rebase failed or push rejected. Run: git rebase --abort then try again.",
|
|
255
|
+
auth_error: "Authentication failed. Check your API tokens and credentials.",
|
|
256
|
+
api_error: "Network connectivity issue. Check your internet connection.",
|
|
257
|
+
};
|
|
258
|
+
|
|
243
259
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
244
260
|
|
|
245
261
|
/** Safely truncate a string for logging / details. */
|
|
@@ -364,6 +380,7 @@ export class ErrorDetector {
|
|
|
364
380
|
return {
|
|
365
381
|
...result,
|
|
366
382
|
severity: PATTERN_SEVERITY[result.pattern] ?? "low",
|
|
383
|
+
remediation: REMEDIATION_HINTS[result.pattern] || null,
|
|
367
384
|
};
|
|
368
385
|
}
|
|
369
386
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.5",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/task-executor.mjs
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
collectDiffStats,
|
|
69
69
|
} from "./diff-stats.mjs";
|
|
70
70
|
import { createAnomalyDetector } from "./anomaly-detector.mjs";
|
|
71
|
-
import { normalizeDedupKey, yieldToEventLoop } from "./utils.mjs";
|
|
71
|
+
import { normalizeDedupKey, yieldToEventLoop, withRetry } from "./utils.mjs";
|
|
72
72
|
import {
|
|
73
73
|
resolveExecutorForTask,
|
|
74
74
|
executorToSdk,
|
|
@@ -3169,10 +3169,20 @@ class TaskExecutor {
|
|
|
3169
3169
|
return;
|
|
3170
3170
|
}
|
|
3171
3171
|
|
|
3172
|
-
// Fetch todo tasks
|
|
3172
|
+
// Fetch todo tasks (with transient-error retry)
|
|
3173
3173
|
let tasks;
|
|
3174
3174
|
try {
|
|
3175
|
-
tasks = await
|
|
3175
|
+
tasks = await withRetry(
|
|
3176
|
+
() => listTasks(projectId, { status: "todo" }),
|
|
3177
|
+
{
|
|
3178
|
+
maxAttempts: 3,
|
|
3179
|
+
baseMs: 2000,
|
|
3180
|
+
retryIf: (err) => {
|
|
3181
|
+
const cat = categorizeError(err);
|
|
3182
|
+
return cat === "transient" || cat === "network";
|
|
3183
|
+
},
|
|
3184
|
+
},
|
|
3185
|
+
);
|
|
3176
3186
|
this._resetListTasksBackoff();
|
|
3177
3187
|
} catch (err) {
|
|
3178
3188
|
this._noteListTasksFailure(err);
|
|
@@ -3252,14 +3262,19 @@ class TaskExecutor {
|
|
|
3252
3262
|
for (const task of toDispatch) {
|
|
3253
3263
|
// Normalize task id
|
|
3254
3264
|
task.id = task.id || task.task_id;
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3265
|
+
try {
|
|
3266
|
+
// Fire and forget — executeTask handles its own lifecycle
|
|
3267
|
+
this.executeTask(task).catch((err) => {
|
|
3268
|
+
console.error(
|
|
3269
|
+
`${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
|
|
3270
|
+
);
|
|
3271
|
+
});
|
|
3272
|
+
} catch (err) {
|
|
3273
|
+
console.warn(`${TAG} slot ${task.id} dispatch error: ${err.message}`);
|
|
3274
|
+
} finally {
|
|
3275
|
+
// ALWAYS yield, even on error, to prevent event loop starvation
|
|
3276
|
+
await yieldToEventLoop();
|
|
3277
|
+
}
|
|
3263
3278
|
}
|
|
3264
3279
|
} catch (err) {
|
|
3265
3280
|
console.error(`${TAG} poll loop error: ${err.message}`);
|
package/ui/app.js
CHANGED
|
@@ -3,12 +3,28 @@
|
|
|
3
3
|
* Modular SPA for Telegram Mini App (no build step)
|
|
4
4
|
* ────────────────────────────────────────────────────────────── */
|
|
5
5
|
|
|
6
|
+
// ── Error telemetry ring buffer (max 50 entries, persisted to sessionStorage) ──
|
|
7
|
+
const MAX_ERROR_LOG = 50;
|
|
8
|
+
function getErrorLog() {
|
|
9
|
+
try { return JSON.parse(sessionStorage.getItem("ve_error_log") || "[]"); } catch { return []; }
|
|
10
|
+
}
|
|
11
|
+
function appendErrorLog(entry) {
|
|
12
|
+
try {
|
|
13
|
+
const log = getErrorLog();
|
|
14
|
+
log.unshift({ ...entry, ts: Date.now() });
|
|
15
|
+
if (log.length > MAX_ERROR_LOG) log.length = MAX_ERROR_LOG;
|
|
16
|
+
sessionStorage.setItem("ve_error_log", JSON.stringify(log));
|
|
17
|
+
} catch { /* quota exceeded */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/* ── Global error handlers — catch unhandled errors before they freeze the UI ── */
|
|
7
21
|
globalThis.addEventListener?.("error", (e) => {
|
|
8
22
|
console.error("[ve:global-error]", e.error || e.message);
|
|
23
|
+
appendErrorLog({ type: "global", message: e.message, stack: e.error?.stack });
|
|
9
24
|
});
|
|
10
25
|
globalThis.addEventListener?.("unhandledrejection", (e) => {
|
|
11
26
|
console.error("[ve:unhandled-rejection]", e.reason);
|
|
27
|
+
appendErrorLog({ type: "rejection", message: String(e.reason?.message || e.reason) });
|
|
12
28
|
});
|
|
13
29
|
|
|
14
30
|
import { h, render as preactRender } from "preact";
|
|
@@ -47,6 +63,8 @@ import {
|
|
|
47
63
|
} from "./modules/api.js";
|
|
48
64
|
import {
|
|
49
65
|
connected,
|
|
66
|
+
statusData,
|
|
67
|
+
executorData,
|
|
50
68
|
refreshTab,
|
|
51
69
|
toasts,
|
|
52
70
|
initWsInvalidationListener,
|
|
@@ -108,6 +126,60 @@ try {
|
|
|
108
126
|
if (stateMod.dataFreshness) dataFreshness = stateMod.dataFreshness;
|
|
109
127
|
} catch { /* use placeholder signals */ }
|
|
110
128
|
|
|
129
|
+
/* ── Shared components ── */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* AnimatedNumber — smoothly counts from previous to new value using rAF.
|
|
133
|
+
*/
|
|
134
|
+
function AnimatedNumber({ value, duration = 600, className = "" }) {
|
|
135
|
+
const displayRef = useRef(value);
|
|
136
|
+
const rafRef = useRef(null);
|
|
137
|
+
const [display, setDisplay] = useState(value);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const from = displayRef.current;
|
|
141
|
+
const to = value;
|
|
142
|
+
if (from === to) return;
|
|
143
|
+
const start = performance.now();
|
|
144
|
+
const animate = (now) => {
|
|
145
|
+
const t = Math.min((now - start) / duration, 1);
|
|
146
|
+
const eased = 1 - Math.pow(1 - t, 3); // ease out cubic
|
|
147
|
+
const current = Math.round(from + (to - from) * eased);
|
|
148
|
+
displayRef.current = current;
|
|
149
|
+
setDisplay(current);
|
|
150
|
+
if (t < 1) rafRef.current = requestAnimationFrame(animate);
|
|
151
|
+
};
|
|
152
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
153
|
+
return () => rafRef.current && cancelAnimationFrame(rafRef.current);
|
|
154
|
+
}, [value, duration]);
|
|
155
|
+
|
|
156
|
+
return html`<span class="${className}">${display}</span>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* KeyboardShortcutsModal — shows available keyboard shortcuts.
|
|
161
|
+
*/
|
|
162
|
+
function KeyboardShortcutsModal({ onClose }) {
|
|
163
|
+
const shortcuts = [
|
|
164
|
+
{ key: "1–8", desc: "Switch tabs" },
|
|
165
|
+
{ key: "c", desc: "Create task (on Dashboard)" },
|
|
166
|
+
{ key: "?", desc: "Show keyboard shortcuts" },
|
|
167
|
+
{ key: "Esc", desc: "Close modal / palette" },
|
|
168
|
+
];
|
|
169
|
+
return html`
|
|
170
|
+
<${Modal} title="Keyboard Shortcuts" onClose=${onClose}>
|
|
171
|
+
<div class="shortcuts-list">
|
|
172
|
+
${shortcuts.map((s) => html`
|
|
173
|
+
<div class="shortcut-item" key=${s.key}>
|
|
174
|
+
<kbd class="shortcut-key">${s.key}</kbd>
|
|
175
|
+
<span class="shortcut-desc">${s.desc}</span>
|
|
176
|
+
</div>
|
|
177
|
+
`)}
|
|
178
|
+
</div>
|
|
179
|
+
<//>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
111
183
|
/* ── Backend health helpers ── */
|
|
112
184
|
|
|
113
185
|
function formatTimeAgo(ts) {
|
|
@@ -285,6 +357,7 @@ class TabErrorBoundary extends Component {
|
|
|
285
357
|
}
|
|
286
358
|
componentDidCatch(error, info) {
|
|
287
359
|
console.error("[TabErrorBoundary] Caught error:", error, info);
|
|
360
|
+
appendErrorLog({ type: "render", tab: this.props.tabName, message: error?.message, stack: error?.stack });
|
|
288
361
|
}
|
|
289
362
|
render() {
|
|
290
363
|
if (this.state.error) {
|
|
@@ -317,6 +390,11 @@ class TabErrorBoundary extends Component {
|
|
|
317
390
|
${stack ? html`<button class="btn btn-ghost btn-sm" onClick=${toggleStack}>
|
|
318
391
|
${this.state.showStack ? "Hide Stack" : "Stack Trace"}
|
|
319
392
|
</button>` : null}
|
|
393
|
+
<button class="btn btn-ghost btn-sm" onClick=${() => {
|
|
394
|
+
console.group("[ve:error-log]");
|
|
395
|
+
getErrorLog().forEach((e, i) => console.log(i, e));
|
|
396
|
+
console.groupEnd();
|
|
397
|
+
}}>Error Log</button>
|
|
320
398
|
</div>
|
|
321
399
|
${this.state.showStack && stack ? html`
|
|
322
400
|
<div class="tab-error-stack">${stack}</div>
|
|
@@ -438,10 +516,18 @@ function SidebarNav() {
|
|
|
438
516
|
${TAB_CONFIG.map((tab) => {
|
|
439
517
|
const isActive = activeTab.value === tab.id;
|
|
440
518
|
const isHome = tab.id === "dashboard";
|
|
519
|
+
const sCounts = statusData.value?.counts || {};
|
|
520
|
+
const badge =
|
|
521
|
+
tab.id === "tasks"
|
|
522
|
+
? Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0)
|
|
523
|
+
: tab.id === "agents"
|
|
524
|
+
? Number(executorData.value?.data?.activeSlots || 0)
|
|
525
|
+
: 0;
|
|
441
526
|
return html`
|
|
442
527
|
<button
|
|
443
528
|
key=${tab.id}
|
|
444
529
|
class="sidebar-nav-item ${isActive ? "active" : ""}"
|
|
530
|
+
style="position:relative"
|
|
445
531
|
aria-label=${tab.label}
|
|
446
532
|
aria-current=${isActive ? "page" : null}
|
|
447
533
|
onClick=${() =>
|
|
@@ -452,6 +538,7 @@ function SidebarNav() {
|
|
|
452
538
|
>
|
|
453
539
|
${ICONS[tab.icon]}
|
|
454
540
|
<span>${tab.label}</span>
|
|
541
|
+
${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
455
542
|
</button>
|
|
456
543
|
`;
|
|
457
544
|
})}
|
|
@@ -683,15 +770,20 @@ function getTabsById(ids) {
|
|
|
683
770
|
|
|
684
771
|
function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
|
|
685
772
|
const primaryTabs = getTabsById(PRIMARY_NAV_TABS);
|
|
773
|
+
const sCounts = statusData.value?.counts || {};
|
|
774
|
+
const tasksBadge = Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0);
|
|
775
|
+
const agentsBadge = Number(executorData.value?.data?.activeSlots || 0);
|
|
686
776
|
return html`
|
|
687
777
|
<nav class=${`bottom-nav ${compact ? "compact" : ""}`}>
|
|
688
778
|
${primaryTabs.map((tab) => {
|
|
689
779
|
const isHome = tab.id === "dashboard";
|
|
690
780
|
const isActive = activeTab.value === tab.id;
|
|
781
|
+
const badge = tab.id === "tasks" ? tasksBadge : tab.id === "agents" ? agentsBadge : 0;
|
|
691
782
|
return html`
|
|
692
783
|
<button
|
|
693
784
|
key=${tab.id}
|
|
694
785
|
class="nav-item ${isActive ? "active" : ""}"
|
|
786
|
+
style="position:relative"
|
|
695
787
|
aria-label=${`Go to ${tab.label}`}
|
|
696
788
|
type="button"
|
|
697
789
|
onClick=${() =>
|
|
@@ -702,6 +794,7 @@ function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
|
|
|
702
794
|
>
|
|
703
795
|
${ICONS[tab.icon]}
|
|
704
796
|
<span class="nav-label">${tab.label}</span>
|
|
797
|
+
${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
705
798
|
</button>
|
|
706
799
|
`;
|
|
707
800
|
})}
|
|
@@ -783,6 +876,7 @@ function MoreSheet({ open, onClose, onNavigate }) {
|
|
|
783
876
|
function App() {
|
|
784
877
|
useBackendHealth();
|
|
785
878
|
const { open: paletteOpen, onClose: paletteClose } = useCommandPalette();
|
|
879
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
786
880
|
const mainRef = useRef(null);
|
|
787
881
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
788
882
|
const scrollVisibilityRef = useRef(false);
|
|
@@ -965,7 +1059,10 @@ function App() {
|
|
|
965
1059
|
|
|
966
1060
|
useEffect(() => {
|
|
967
1061
|
if (typeof globalThis === "undefined") return;
|
|
968
|
-
const handler = () =>
|
|
1062
|
+
const handler = () => {
|
|
1063
|
+
setIsMoreOpen(false);
|
|
1064
|
+
setShowShortcuts(false);
|
|
1065
|
+
};
|
|
969
1066
|
globalThis.addEventListener("ve:close-modals", handler);
|
|
970
1067
|
return () => globalThis.removeEventListener("ve:close-modals", handler);
|
|
971
1068
|
}, []);
|
|
@@ -1016,9 +1113,24 @@ function App() {
|
|
|
1016
1113
|
return;
|
|
1017
1114
|
}
|
|
1018
1115
|
|
|
1116
|
+
// "c" to create task (when not in a form element)
|
|
1117
|
+
if (e.key === "c") {
|
|
1118
|
+
e.preventDefault();
|
|
1119
|
+
globalThis.dispatchEvent(new CustomEvent("ve:create-task"));
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// "?" to toggle keyboard shortcuts help
|
|
1124
|
+
if (e.key === "?") {
|
|
1125
|
+
e.preventDefault();
|
|
1126
|
+
setShowShortcuts((v) => !v);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1019
1130
|
// Escape to close modals/palette
|
|
1020
1131
|
if (e.key === "Escape") {
|
|
1021
1132
|
globalThis.dispatchEvent(new CustomEvent("ve:close-modals"));
|
|
1133
|
+
setShowShortcuts(false);
|
|
1022
1134
|
}
|
|
1023
1135
|
}
|
|
1024
1136
|
document.addEventListener("keydown", handleGlobalKeys);
|
|
@@ -1223,6 +1335,7 @@ function App() {
|
|
|
1223
1335
|
${backendDown.value ? html`<${OfflineBanner} />` : null}
|
|
1224
1336
|
<${ToastContainer} />
|
|
1225
1337
|
<${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
|
|
1338
|
+
${showShortcuts ? html`<${KeyboardShortcutsModal} onClose=${() => setShowShortcuts(false)} />` : null}
|
|
1226
1339
|
<${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
|
|
1227
1340
|
<main class="main-content" ref=${mainRef}>
|
|
1228
1341
|
<${TabErrorBoundary} key=${activeTab.value} tabName=${activeTab.value}>
|
package/ui/modules/api.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { signal } from "@preact/signals";
|
|
7
7
|
import { getInitData } from "./telegram.js";
|
|
8
8
|
|
|
9
|
+
/** Map of in-flight GET request promises, keyed by path */
|
|
10
|
+
const _inflight = new Map();
|
|
11
|
+
|
|
9
12
|
/** Reactive signal: whether the WebSocket is currently connected */
|
|
10
13
|
export const wsConnected = signal(false);
|
|
11
14
|
/** Reactive signal: WebSocket round-trip latency in ms (null if unknown) */
|
|
@@ -27,7 +30,7 @@ export const loadingCount = signal(0);
|
|
|
27
30
|
* @param {RequestInit & {_silent?: boolean}} options
|
|
28
31
|
* @returns {Promise<any>} parsed JSON body
|
|
29
32
|
*/
|
|
30
|
-
export
|
|
33
|
+
export function apiFetch(path, options = {}) {
|
|
31
34
|
const headers = { ...options.headers };
|
|
32
35
|
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
33
36
|
|
|
@@ -39,30 +42,59 @@ export async function apiFetch(path, options = {}) {
|
|
|
39
42
|
const silent = options._silent;
|
|
40
43
|
delete options._silent;
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
throw new Error(text || `Request failed (${res.status})`);
|
|
45
|
+
// Deduplicate concurrent identical GETs
|
|
46
|
+
const isGet = !options.method || options.method === "GET";
|
|
47
|
+
if (isGet && !options.body) {
|
|
48
|
+
if (_inflight.has(path)) {
|
|
49
|
+
return _inflight.get(path);
|
|
48
50
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Retry config for network-level failures only (not 4xx/5xx HTTP errors)
|
|
54
|
+
const MAX_FETCH_RETRIES = 2;
|
|
55
|
+
const FETCH_RETRY_BASE_MS = 800;
|
|
56
|
+
|
|
57
|
+
const promise = (async () => {
|
|
58
|
+
loadingCount.value += 1;
|
|
59
|
+
let res;
|
|
60
|
+
let fetchAttempt = 0;
|
|
61
|
+
try {
|
|
62
|
+
while (fetchAttempt <= MAX_FETCH_RETRIES) {
|
|
63
|
+
try {
|
|
64
|
+
res = await fetch(path, { ...options, headers });
|
|
65
|
+
break; // success — exit retry loop
|
|
66
|
+
} catch (networkErr) {
|
|
67
|
+
fetchAttempt++;
|
|
68
|
+
if (fetchAttempt > MAX_FETCH_RETRIES || silent) throw networkErr;
|
|
69
|
+
await new Promise((r) => setTimeout(r, FETCH_RETRY_BASE_MS * fetchAttempt));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const text = await res.text().catch(() => "");
|
|
74
|
+
throw new Error(text || `Request failed (${res.status})`);
|
|
60
75
|
}
|
|
76
|
+
return await res.json();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Re-throw so callers can catch, but don't toast on silent requests
|
|
79
|
+
if (!silent) {
|
|
80
|
+
// Dispatch a custom event so the state layer can show a toast
|
|
81
|
+
try {
|
|
82
|
+
globalThis.dispatchEvent(
|
|
83
|
+
new CustomEvent("ve:api-error", { detail: { message: err.message } }),
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
/* noop */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
} finally {
|
|
91
|
+
loadingCount.value = Math.max(0, loadingCount.value - 1);
|
|
92
|
+
if (isGet && !options.body) _inflight.delete(path);
|
|
61
93
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
94
|
+
})();
|
|
95
|
+
|
|
96
|
+
if (isGet && !options.body) _inflight.set(path, promise);
|
|
97
|
+
return promise;
|
|
66
98
|
}
|
|
67
99
|
|
|
68
100
|
/* ─── Command Sending ─── */
|
package/ui/styles/components.css
CHANGED
|
@@ -4079,3 +4079,167 @@ select.input {
|
|
|
4079
4079
|
max-width: 340px;
|
|
4080
4080
|
line-height: 1.6;
|
|
4081
4081
|
}
|
|
4082
|
+
|
|
4083
|
+
/* ═══════════════════════════════════════════════
|
|
4084
|
+
* Nav Badges
|
|
4085
|
+
* ═══════════════════════════════════════════════ */
|
|
4086
|
+
|
|
4087
|
+
.nav-badge {
|
|
4088
|
+
display: inline-flex;
|
|
4089
|
+
align-items: center;
|
|
4090
|
+
justify-content: center;
|
|
4091
|
+
min-width: 18px;
|
|
4092
|
+
height: 18px;
|
|
4093
|
+
padding: 0 5px;
|
|
4094
|
+
border-radius: 99px;
|
|
4095
|
+
background: var(--accent);
|
|
4096
|
+
color: var(--accent-text, #fff);
|
|
4097
|
+
font-size: 10px;
|
|
4098
|
+
font-weight: 700;
|
|
4099
|
+
line-height: 1;
|
|
4100
|
+
position: absolute;
|
|
4101
|
+
top: 2px;
|
|
4102
|
+
right: 2px;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
/* ═══════════════════════════════════════════════
|
|
4106
|
+
* Dashboard Clock Chip
|
|
4107
|
+
* ═══════════════════════════════════════════════ */
|
|
4108
|
+
|
|
4109
|
+
.dashboard-chip-clock {
|
|
4110
|
+
font-variant-numeric: tabular-nums;
|
|
4111
|
+
font-family: var(--font-mono, monospace);
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
.dashboard-chip-tz {
|
|
4115
|
+
font-size: 10px;
|
|
4116
|
+
opacity: 0.7;
|
|
4117
|
+
margin-left: 4px;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
/* ═══════════════════════════════════════════════
|
|
4121
|
+
* Dashboard Health Score
|
|
4122
|
+
* ═══════════════════════════════════════════════ */
|
|
4123
|
+
|
|
4124
|
+
.dashboard-health-score {
|
|
4125
|
+
text-align: center;
|
|
4126
|
+
padding: 12px 0;
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
.health-score-value {
|
|
4130
|
+
font-size: 48px;
|
|
4131
|
+
font-weight: 800;
|
|
4132
|
+
line-height: 1;
|
|
4133
|
+
letter-spacing: -0.05em;
|
|
4134
|
+
font-family: var(--font-mono, monospace);
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
.health-score-label {
|
|
4138
|
+
font-size: 11px;
|
|
4139
|
+
text-transform: uppercase;
|
|
4140
|
+
letter-spacing: 0.1em;
|
|
4141
|
+
color: var(--text-secondary);
|
|
4142
|
+
margin-top: 4px;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
/* ═══════════════════════════════════════════════
|
|
4146
|
+
* Dashboard Recent Commits
|
|
4147
|
+
* ═══════════════════════════════════════════════ */
|
|
4148
|
+
|
|
4149
|
+
.dashboard-commits {
|
|
4150
|
+
display: flex;
|
|
4151
|
+
flex-direction: column;
|
|
4152
|
+
gap: 8px;
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
.dashboard-commit-item {
|
|
4156
|
+
display: grid;
|
|
4157
|
+
grid-template-columns: 56px 1fr;
|
|
4158
|
+
grid-template-rows: auto auto;
|
|
4159
|
+
column-gap: 10px;
|
|
4160
|
+
padding: 8px 10px;
|
|
4161
|
+
border-radius: 8px;
|
|
4162
|
+
background: var(--surface-secondary, rgba(255,255,255,0.04));
|
|
4163
|
+
border: 1px solid var(--border);
|
|
4164
|
+
}
|
|
4165
|
+
|
|
4166
|
+
.dashboard-commit-hash {
|
|
4167
|
+
grid-row: 1 / 3;
|
|
4168
|
+
font-family: var(--font-mono, monospace);
|
|
4169
|
+
font-size: 11px;
|
|
4170
|
+
color: var(--accent);
|
|
4171
|
+
font-weight: 600;
|
|
4172
|
+
align-self: center;
|
|
4173
|
+
letter-spacing: 0.03em;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
.dashboard-commit-msg {
|
|
4177
|
+
font-size: 13px;
|
|
4178
|
+
color: var(--text-primary);
|
|
4179
|
+
white-space: nowrap;
|
|
4180
|
+
overflow: hidden;
|
|
4181
|
+
text-overflow: ellipsis;
|
|
4182
|
+
}
|
|
4183
|
+
|
|
4184
|
+
.dashboard-commit-meta {
|
|
4185
|
+
font-size: 11px;
|
|
4186
|
+
color: var(--text-secondary);
|
|
4187
|
+
margin-top: 2px;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
/* ═══════════════════════════════════════════════
|
|
4191
|
+
* Stat Flash Animation
|
|
4192
|
+
* ═══════════════════════════════════════════════ */
|
|
4193
|
+
|
|
4194
|
+
@keyframes statFlash {
|
|
4195
|
+
0% { background: rgba(59, 130, 246, 0.15); }
|
|
4196
|
+
100% { background: transparent; }
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
.stat-flash {
|
|
4200
|
+
animation: statFlash 0.4s ease;
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
/* ═══════════════════════════════════════════════
|
|
4204
|
+
* Keyboard Shortcuts Modal
|
|
4205
|
+
* ═══════════════════════════════════════════════ */
|
|
4206
|
+
|
|
4207
|
+
.shortcuts-list {
|
|
4208
|
+
display: flex;
|
|
4209
|
+
flex-direction: column;
|
|
4210
|
+
gap: 6px;
|
|
4211
|
+
padding: 4px 0;
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4214
|
+
.shortcut-item {
|
|
4215
|
+
display: flex;
|
|
4216
|
+
align-items: center;
|
|
4217
|
+
gap: 12px;
|
|
4218
|
+
padding: 6px 0;
|
|
4219
|
+
border-bottom: 1px solid var(--border);
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
.shortcut-item:last-child {
|
|
4223
|
+
border-bottom: none;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
.shortcut-key {
|
|
4227
|
+
display: inline-flex;
|
|
4228
|
+
align-items: center;
|
|
4229
|
+
justify-content: center;
|
|
4230
|
+
min-width: 40px;
|
|
4231
|
+
padding: 3px 8px;
|
|
4232
|
+
border-radius: 6px;
|
|
4233
|
+
background: var(--surface-secondary, rgba(255,255,255,0.06));
|
|
4234
|
+
border: 1px solid var(--border);
|
|
4235
|
+
font-family: var(--font-mono, monospace);
|
|
4236
|
+
font-size: 12px;
|
|
4237
|
+
font-weight: 600;
|
|
4238
|
+
color: var(--text-primary);
|
|
4239
|
+
white-space: nowrap;
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
.shortcut-desc {
|
|
4243
|
+
font-size: 13px;
|
|
4244
|
+
color: var(--text-secondary);
|
|
4245
|
+
}
|
package/ui/tabs/dashboard.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useState,
|
|
7
7
|
useEffect,
|
|
8
8
|
useCallback,
|
|
9
|
+
useRef,
|
|
9
10
|
} from "preact/hooks";
|
|
10
11
|
import htm from "htm";
|
|
11
12
|
|
|
@@ -96,6 +97,32 @@ const QUICK_ACTIONS = [
|
|
|
96
97
|
},
|
|
97
98
|
];
|
|
98
99
|
|
|
100
|
+
/* ─── AnimatedNumber ─── */
|
|
101
|
+
function AnimatedNumber({ value, duration = 600, className = "" }) {
|
|
102
|
+
const displayRef = useRef(value);
|
|
103
|
+
const rafRef = useRef(null);
|
|
104
|
+
const [display, setDisplay] = useState(value);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const from = displayRef.current;
|
|
108
|
+
const to = value;
|
|
109
|
+
if (from === to) return;
|
|
110
|
+
const start = performance.now();
|
|
111
|
+
const animate = (now) => {
|
|
112
|
+
const t = Math.min((now - start) / duration, 1);
|
|
113
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
114
|
+
const current = Math.round(from + (to - from) * eased);
|
|
115
|
+
displayRef.current = current;
|
|
116
|
+
setDisplay(current);
|
|
117
|
+
if (t < 1) rafRef.current = requestAnimationFrame(animate);
|
|
118
|
+
};
|
|
119
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
120
|
+
return () => rafRef.current && cancelAnimationFrame(rafRef.current);
|
|
121
|
+
}, [value, duration]);
|
|
122
|
+
|
|
123
|
+
return html`<span class="${className}">${display}</span>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
99
126
|
/* ─── CreateTaskModal ─── */
|
|
100
127
|
export function CreateTaskModal({ onClose }) {
|
|
101
128
|
const [title, setTitle] = useState("");
|
|
@@ -190,6 +217,11 @@ export function DashboardTab() {
|
|
|
190
217
|
const [showCreate, setShowCreate] = useState(false);
|
|
191
218
|
const [showStartModal, setShowStartModal] = useState(false);
|
|
192
219
|
const [uptime, setUptime] = useState(null);
|
|
220
|
+
// New state
|
|
221
|
+
const [now, setNow] = useState(() => new Date());
|
|
222
|
+
const [recentCommits, setRecentCommits] = useState([]);
|
|
223
|
+
const [flashKey, setFlashKey] = useState(0);
|
|
224
|
+
const prevCounts = useRef(null);
|
|
193
225
|
const status = statusData.value;
|
|
194
226
|
const executor = executorData.value;
|
|
195
227
|
const project = projectSummary.value;
|
|
@@ -216,6 +248,19 @@ export function DashboardTab() {
|
|
|
216
248
|
const slotPct = execData?.maxParallel
|
|
217
249
|
? ((execData.activeSlots || 0) / execData.maxParallel) * 100
|
|
218
250
|
: 0;
|
|
251
|
+
|
|
252
|
+
// ── Health score (0–100) ──
|
|
253
|
+
let healthScore = 100;
|
|
254
|
+
if (executor?.paused) healthScore -= 20;
|
|
255
|
+
healthScore -= Math.min(40, errorRateValue * 2);
|
|
256
|
+
if ((execData?.activeSlots ?? 0) === 0 && backlog > 0) healthScore -= 10;
|
|
257
|
+
if (slotPct > 50 && blocked === 0) healthScore += 10;
|
|
258
|
+
healthScore = Math.min(100, Math.max(0, Math.round(healthScore)));
|
|
259
|
+
|
|
260
|
+
// ── Clock ──
|
|
261
|
+
const timeStr = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
262
|
+
const tzStr = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
263
|
+
|
|
219
264
|
const headerLine = `${totalActive} active · ${backlog} backlog · ${done} done${
|
|
220
265
|
blocked ? ` · ${blocked} blocked` : ""
|
|
221
266
|
}`;
|
|
@@ -258,6 +303,36 @@ export function DashboardTab() {
|
|
|
258
303
|
return () => { active = false; };
|
|
259
304
|
}, []);
|
|
260
305
|
|
|
306
|
+
// ── Listen for ve:create-task keyboard shortcut ──
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const handler = () => setShowCreate(true);
|
|
309
|
+
window.addEventListener("ve:create-task", handler);
|
|
310
|
+
return () => window.removeEventListener("ve:create-task", handler);
|
|
311
|
+
}, []);
|
|
312
|
+
|
|
313
|
+
// ── Real-time clock ──
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const t = setInterval(() => setNow(new Date()), 1000);
|
|
316
|
+
return () => clearInterval(t);
|
|
317
|
+
}, []);
|
|
318
|
+
|
|
319
|
+
// ── Recent commits (graceful 404) ──
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
apiFetch("/api/recent-commits", { _silent: true })
|
|
322
|
+
.then((data) => { if (Array.isArray(data)) setRecentCommits(data.slice(0, 3)); })
|
|
323
|
+
.catch(() => {});
|
|
324
|
+
}, []);
|
|
325
|
+
|
|
326
|
+
// ── Flash metrics on counts change ──
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
const current = JSON.stringify(counts);
|
|
329
|
+
const previous = JSON.stringify(prevCounts.current);
|
|
330
|
+
if (previous !== current) {
|
|
331
|
+
prevCounts.current = counts;
|
|
332
|
+
setFlashKey((k) => k + 1);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
261
336
|
const overviewMetrics = [
|
|
262
337
|
{
|
|
263
338
|
label: "Total tasks",
|
|
@@ -496,6 +571,9 @@ export function DashboardTab() {
|
|
|
496
571
|
<span class="dashboard-chip">Mode ${mode}</span>
|
|
497
572
|
<span class="dashboard-chip">SDK ${defaultSdk}</span>
|
|
498
573
|
${uptime ? html`<span class="dashboard-chip">${uptime}</span>` : null}
|
|
574
|
+
<span class="dashboard-chip dashboard-chip-clock">
|
|
575
|
+
${timeStr} <span class="dashboard-chip-tz">${tzStr}</span>
|
|
576
|
+
</span>
|
|
499
577
|
${executor
|
|
500
578
|
? executor.paused
|
|
501
579
|
? html`<${Badge} status="error" text="Paused" />`
|
|
@@ -518,6 +596,10 @@ export function DashboardTab() {
|
|
|
518
596
|
${executor?.paused ? "Paused" : "Running"}
|
|
519
597
|
</div>
|
|
520
598
|
</div>
|
|
599
|
+
<div class="dashboard-health-score">
|
|
600
|
+
<div class="health-score-value" style="color: ${healthScore >= 80 ? 'var(--color-done)' : healthScore >= 50 ? 'var(--color-inreview)' : 'var(--color-error)'}">${healthScore}</div>
|
|
601
|
+
<div class="health-score-label">Health Score</div>
|
|
602
|
+
</div>
|
|
521
603
|
<div class="dashboard-health-grid">
|
|
522
604
|
<div class="dashboard-health-item">
|
|
523
605
|
<div class="dashboard-health-label">Slots</div>
|
|
@@ -594,7 +676,7 @@ export function DashboardTab() {
|
|
|
594
676
|
</div>
|
|
595
677
|
`
|
|
596
678
|
: html`
|
|
597
|
-
<div class="dashboard-metric-grid">
|
|
679
|
+
<div class="dashboard-metric-grid stat-flash" key=${flashKey}>
|
|
598
680
|
${overviewMetrics.map(
|
|
599
681
|
(metric) => html`
|
|
600
682
|
<div
|
|
@@ -610,7 +692,9 @@ export function DashboardTab() {
|
|
|
610
692
|
class="dashboard-metric-value"
|
|
611
693
|
style="color: ${metric.color}"
|
|
612
694
|
>
|
|
613
|
-
${metric.value
|
|
695
|
+
${typeof metric.value === "number"
|
|
696
|
+
? html`<${AnimatedNumber} value=${metric.value} />`
|
|
697
|
+
: metric.value} ${trend(metric.trend)}
|
|
614
698
|
</div>
|
|
615
699
|
<div class="dashboard-metric-spark">
|
|
616
700
|
<${MiniSparkline}
|
|
@@ -798,6 +882,23 @@ export function DashboardTab() {
|
|
|
798
882
|
${showCreate &&
|
|
799
883
|
html`<${CreateTaskModal} onClose=${() => setShowCreate(false)} />`}
|
|
800
884
|
|
|
885
|
+
${recentCommits.length > 0 && html`
|
|
886
|
+
<${Card}
|
|
887
|
+
title=${html`<span class="dashboard-card-title"><span class="dashboard-title-icon">${ICONS.git || '🔀'}</span>Recent Commits</span>`}
|
|
888
|
+
className="dashboard-card dashboard-commits-card"
|
|
889
|
+
>
|
|
890
|
+
<div class="dashboard-commits">
|
|
891
|
+
${recentCommits.map((c) => html`
|
|
892
|
+
<div class="dashboard-commit-item" key=${c.hash || c.message}>
|
|
893
|
+
<div class="dashboard-commit-hash">${(c.hash || '').slice(0, 7)}</div>
|
|
894
|
+
<div class="dashboard-commit-msg">${truncate(c.message || c.msg || '', 60)}</div>
|
|
895
|
+
<div class="dashboard-commit-meta">${c.author || ''} · ${formatRelative(c.date || c.timestamp)}</div>
|
|
896
|
+
</div>
|
|
897
|
+
`)}
|
|
898
|
+
</div>
|
|
899
|
+
<//>
|
|
900
|
+
`}
|
|
901
|
+
|
|
801
902
|
${showStartModal &&
|
|
802
903
|
html`
|
|
803
904
|
<${StartTaskModal}
|
package/utils.mjs
CHANGED
|
@@ -217,3 +217,63 @@ export async function spawnAsync(cmd, args = [], options = {}) {
|
|
|
217
217
|
export function yieldToEventLoop() {
|
|
218
218
|
return new Promise((resolve) => setImmediate(resolve));
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* withRetry — run an async fn with exponential backoff on failure.
|
|
223
|
+
*
|
|
224
|
+
* @template T
|
|
225
|
+
* @param {() => Promise<T>} fn — async operation to retry
|
|
226
|
+
* @param {object} [opts]
|
|
227
|
+
* @param {number} [opts.maxAttempts=3] — total attempts
|
|
228
|
+
* @param {number} [opts.baseMs=1000] — initial delay
|
|
229
|
+
* @param {number} [opts.maxMs=30000] — cap delay
|
|
230
|
+
* @param {(err: Error, attempt: number) => boolean} [opts.retryIf] — optional guard
|
|
231
|
+
* @returns {Promise<T>}
|
|
232
|
+
*/
|
|
233
|
+
export async function withRetry(fn, opts = {}) {
|
|
234
|
+
const { maxAttempts = 3, baseMs = 1000, maxMs = 30000, retryIf } = opts;
|
|
235
|
+
let lastErr;
|
|
236
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
237
|
+
try {
|
|
238
|
+
return await fn();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
lastErr = err;
|
|
241
|
+
if (attempt >= maxAttempts) break;
|
|
242
|
+
if (retryIf && !retryIf(err, attempt)) break;
|
|
243
|
+
const jitter = Math.random() * 0.3 + 0.85; // 0.85–1.15x
|
|
244
|
+
const delay = Math.min(baseMs * Math.pow(2, attempt - 1) * jitter, maxMs);
|
|
245
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
throw lastErr;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* memoizeWithTtl — cache the result of a zero-args async factory for `ttlMs`.
|
|
253
|
+
* Returns a getter function that resolves the cached value or refreshes it.
|
|
254
|
+
*
|
|
255
|
+
* @template T
|
|
256
|
+
* @param {() => Promise<T>} factory
|
|
257
|
+
* @param {number} ttlMs
|
|
258
|
+
* @returns {() => Promise<T>}
|
|
259
|
+
*/
|
|
260
|
+
export function memoizeWithTtl(factory, ttlMs) {
|
|
261
|
+
let cache = null;
|
|
262
|
+
let expiry = 0;
|
|
263
|
+
let pending = null;
|
|
264
|
+
return async function get() {
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
if (cache !== null && now < expiry) return cache;
|
|
267
|
+
if (pending) return pending;
|
|
268
|
+
pending = factory().then((v) => {
|
|
269
|
+
cache = v;
|
|
270
|
+
expiry = Date.now() + ttlMs;
|
|
271
|
+
pending = null;
|
|
272
|
+
return v;
|
|
273
|
+
}).catch((e) => {
|
|
274
|
+
pending = null;
|
|
275
|
+
throw e;
|
|
276
|
+
});
|
|
277
|
+
return pending;
|
|
278
|
+
};
|
|
279
|
+
}
|