@a5c-ai/babysitter-observer-dashboard 1.0.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/LICENSE +21 -0
- package/README.md +490 -0
- package/next.config.mjs +25 -0
- package/package.json +104 -0
- package/postcss.config.mjs +8 -0
- package/src/app/actions/__tests__/approve-breakpoint.test.ts +246 -0
- package/src/app/actions/approve-breakpoint.ts +145 -0
- package/src/app/api/config/route.ts +137 -0
- package/src/app/api/digest/route.ts +45 -0
- package/src/app/api/runs/[runId]/events/route.ts +56 -0
- package/src/app/api/runs/[runId]/route.ts +84 -0
- package/src/app/api/runs/[runId]/tasks/[effectId]/route.ts +44 -0
- package/src/app/api/runs/route.ts +48 -0
- package/src/app/api/stream/route.ts +136 -0
- package/src/app/api/test/route.ts +1 -0
- package/src/app/api/version/route.ts +57 -0
- package/src/app/globals.css +555 -0
- package/src/app/icon.svg +20 -0
- package/src/app/layout.tsx +39 -0
- package/src/app/not-found.tsx +16 -0
- package/src/app/page.tsx +120 -0
- package/src/app/runs/[runId]/page.tsx +279 -0
- package/src/cli.ts +271 -0
- package/src/components/breakpoint/__tests__/breakpoint-approval.test.tsx +212 -0
- package/src/components/breakpoint/__tests__/breakpoint-panel.test.tsx +130 -0
- package/src/components/breakpoint/__tests__/file-preview.test.tsx +313 -0
- package/src/components/breakpoint/breakpoint-approval.tsx +138 -0
- package/src/components/breakpoint/breakpoint-panel.tsx +95 -0
- package/src/components/breakpoint/file-preview.tsx +215 -0
- package/src/components/dashboard/.gitkeep +0 -0
- package/src/components/dashboard/__tests__/breakpoint-banner.test.tsx +177 -0
- package/src/components/dashboard/__tests__/catch-up-banner.test.tsx +141 -0
- package/src/components/dashboard/__tests__/executive-summary-banner.test.tsx +164 -0
- package/src/components/dashboard/__tests__/kpi-grid.test.tsx +101 -0
- package/src/components/dashboard/__tests__/pagination-controls.test.tsx +125 -0
- package/src/components/dashboard/__tests__/project-accordion.test.tsx +97 -0
- package/src/components/dashboard/__tests__/project-list-view.test.tsx +174 -0
- package/src/components/dashboard/__tests__/project-search-input.test.tsx +110 -0
- package/src/components/dashboard/__tests__/project-section-header.test.tsx +91 -0
- package/src/components/dashboard/__tests__/project-section.test.tsx +151 -0
- package/src/components/dashboard/__tests__/run-card.test.tsx +164 -0
- package/src/components/dashboard/__tests__/run-filter-bar.test.tsx +109 -0
- package/src/components/dashboard/__tests__/run-list.test.tsx +123 -0
- package/src/components/dashboard/__tests__/search-filter.test.tsx +150 -0
- package/src/components/dashboard/__tests__/virtualized-run-list.test.tsx +179 -0
- package/src/components/dashboard/breakpoint-banner.tsx +301 -0
- package/src/components/dashboard/catch-up-banner.tsx +88 -0
- package/src/components/dashboard/executive-summary-banner.tsx +174 -0
- package/src/components/dashboard/global-search.tsx +323 -0
- package/src/components/dashboard/kpi-grid.tsx +140 -0
- package/src/components/dashboard/pagination-controls.tsx +100 -0
- package/src/components/dashboard/project-accordion.tsx +72 -0
- package/src/components/dashboard/project-health-card.tsx +536 -0
- package/src/components/dashboard/project-list-view.tsx +246 -0
- package/src/components/dashboard/project-search-input.tsx +41 -0
- package/src/components/dashboard/project-section-header.tsx +73 -0
- package/src/components/dashboard/project-section.tsx +89 -0
- package/src/components/dashboard/run-card.tsx +218 -0
- package/src/components/dashboard/run-filter-bar.tsx +100 -0
- package/src/components/dashboard/run-list.tsx +77 -0
- package/src/components/dashboard/search-filter.tsx +69 -0
- package/src/components/dashboard/virtualized-run-list.tsx +130 -0
- package/src/components/details/.gitkeep +0 -0
- package/src/components/details/__tests__/agent-panel.test.tsx +236 -0
- package/src/components/details/__tests__/json-tree.test.tsx +347 -0
- package/src/components/details/__tests__/log-viewer.test.tsx +168 -0
- package/src/components/details/__tests__/task-detail.test.tsx +212 -0
- package/src/components/details/__tests__/timing-panel.test.tsx +271 -0
- package/src/components/details/agent-panel.tsx +234 -0
- package/src/components/details/json-tree/categorize.ts +131 -0
- package/src/components/details/json-tree/index.tsx +120 -0
- package/src/components/details/json-tree/json-node.tsx +223 -0
- package/src/components/details/json-tree/smart-summary.tsx +596 -0
- package/src/components/details/json-tree/tree-controls.tsx +47 -0
- package/src/components/details/json-tree.tsx +9 -0
- package/src/components/details/log-viewer.tsx +140 -0
- package/src/components/details/task-detail.tsx +114 -0
- package/src/components/details/timing-panel.tsx +247 -0
- package/src/components/events/.gitkeep +0 -0
- package/src/components/events/__tests__/event-item.test.tsx +211 -0
- package/src/components/events/__tests__/event-stream.test.tsx +225 -0
- package/src/components/events/event-item.tsx +121 -0
- package/src/components/events/event-stream.tsx +260 -0
- package/src/components/notifications/.gitkeep +0 -0
- package/src/components/notifications/__tests__/notification-panel.test.tsx +287 -0
- package/src/components/notifications/__tests__/notification-provider.test.tsx +585 -0
- package/src/components/notifications/__tests__/toast-stack.test.tsx +217 -0
- package/src/components/notifications/notification-panel.tsx +124 -0
- package/src/components/notifications/notification-provider.tsx +175 -0
- package/src/components/notifications/toast-stack.tsx +75 -0
- package/src/components/pipeline/.gitkeep +0 -0
- package/src/components/pipeline/__tests__/parallel-group.test.tsx +88 -0
- package/src/components/pipeline/__tests__/pipeline-view.test.tsx +345 -0
- package/src/components/pipeline/__tests__/step-card.test.tsx +330 -0
- package/src/components/pipeline/parallel-group.tsx +39 -0
- package/src/components/pipeline/pipeline-view.tsx +197 -0
- package/src/components/pipeline/step-card.tsx +166 -0
- package/src/components/providers/event-stream-provider.tsx +29 -0
- package/src/components/providers.tsx +24 -0
- package/src/components/shared/.gitkeep +0 -0
- package/src/components/shared/__tests__/empty-state.test.tsx +49 -0
- package/src/components/shared/__tests__/friendly-id.test.tsx +47 -0
- package/src/components/shared/__tests__/kbd.test.tsx +45 -0
- package/src/components/shared/__tests__/kind-badge.test.tsx +71 -0
- package/src/components/shared/__tests__/metrics-row.test.tsx +74 -0
- package/src/components/shared/__tests__/outcome-banner.test.tsx +71 -0
- package/src/components/shared/__tests__/progress-bar.test.tsx +89 -0
- package/src/components/shared/__tests__/session-pill.test.tsx +62 -0
- package/src/components/shared/__tests__/settings-modal.test.tsx +201 -0
- package/src/components/shared/__tests__/shortcuts-help.test.tsx +103 -0
- package/src/components/shared/__tests__/status-badge.test.tsx +98 -0
- package/src/components/shared/__tests__/theme-provider.test.tsx +100 -0
- package/src/components/shared/__tests__/truncated-id.test.tsx +53 -0
- package/src/components/shared/app-footer.tsx +80 -0
- package/src/components/shared/app-header.tsx +160 -0
- package/src/components/shared/empty-state.tsx +18 -0
- package/src/components/shared/error-boundary.tsx +81 -0
- package/src/components/shared/friendly-id.tsx +48 -0
- package/src/components/shared/kbd.tsx +15 -0
- package/src/components/shared/kind-badge.tsx +51 -0
- package/src/components/shared/metrics-row.tsx +106 -0
- package/src/components/shared/outcome-banner.tsx +56 -0
- package/src/components/shared/progress-bar.tsx +42 -0
- package/src/components/shared/session-pill.tsx +69 -0
- package/src/components/shared/settings-modal.tsx +509 -0
- package/src/components/shared/shortcuts-help.tsx +113 -0
- package/src/components/shared/status-badge.tsx +110 -0
- package/src/components/shared/theme-provider.tsx +46 -0
- package/src/components/shared/truncated-id.tsx +51 -0
- package/src/components/ui/.gitkeep +0 -0
- package/src/components/ui/__tests__/accordion.test.tsx +96 -0
- package/src/components/ui/__tests__/badge.test.tsx +69 -0
- package/src/components/ui/__tests__/button.test.tsx +113 -0
- package/src/components/ui/__tests__/tabs.test.tsx +75 -0
- package/src/components/ui/__tests__/tooltip.test.tsx +90 -0
- package/src/components/ui/accordion.tsx +61 -0
- package/src/components/ui/badge.tsx +25 -0
- package/src/components/ui/button.tsx +40 -0
- package/src/components/ui/card.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +35 -0
- package/src/components/ui/separator.tsx +24 -0
- package/src/components/ui/tabs.tsx +64 -0
- package/src/components/ui/tooltip.tsx +37 -0
- package/src/hooks/.gitkeep +0 -0
- package/src/hooks/__tests__/use-animated-number.test.ts +184 -0
- package/src/hooks/__tests__/use-batched-updates.test.ts +315 -0
- package/src/hooks/__tests__/use-event-stream.test.ts +243 -0
- package/src/hooks/__tests__/use-keyboard.test.ts +217 -0
- package/src/hooks/__tests__/use-notifications.test.ts +230 -0
- package/src/hooks/__tests__/use-polling.test.ts +274 -0
- package/src/hooks/__tests__/use-project-runs.test.ts +163 -0
- package/src/hooks/__tests__/use-projects.test.ts +248 -0
- package/src/hooks/__tests__/use-run-dashboard.test.ts +168 -0
- package/src/hooks/__tests__/use-run-detail.test.ts +273 -0
- package/src/hooks/__tests__/use-smart-polling.test.ts +305 -0
- package/src/hooks/use-animated-number.ts +87 -0
- package/src/hooks/use-batched-updates.ts +150 -0
- package/src/hooks/use-event-stream.ts +150 -0
- package/src/hooks/use-keyboard.ts +45 -0
- package/src/hooks/use-notifications.ts +82 -0
- package/src/hooks/use-persisted-state.ts +60 -0
- package/src/hooks/use-polling.ts +60 -0
- package/src/hooks/use-project-runs.ts +51 -0
- package/src/hooks/use-projects.ts +26 -0
- package/src/hooks/use-run-dashboard.ts +207 -0
- package/src/hooks/use-run-detail.ts +77 -0
- package/src/hooks/use-smart-polling.ts +144 -0
- package/src/lib/.gitkeep +0 -0
- package/src/lib/__tests__/cn.test.ts +69 -0
- package/src/lib/__tests__/config-loader.test.ts +210 -0
- package/src/lib/__tests__/config.test.ts +561 -0
- package/src/lib/__tests__/error-handler.test.ts +143 -0
- package/src/lib/__tests__/fetcher.test.ts +517 -0
- package/src/lib/__tests__/global-registry.test.ts +214 -0
- package/src/lib/__tests__/parser.test.ts +1532 -0
- package/src/lib/__tests__/path-resolver.test.ts +112 -0
- package/src/lib/__tests__/run-cache.test.ts +591 -0
- package/src/lib/__tests__/server-init.test.ts +512 -0
- package/src/lib/__tests__/source-discovery.test.ts +246 -0
- package/src/lib/__tests__/utils.test.ts +160 -0
- package/src/lib/__tests__/watcher.test.ts +227 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/config-loader.ts +195 -0
- package/src/lib/config.ts +20 -0
- package/src/lib/error-handler.ts +76 -0
- package/src/lib/fetcher.ts +394 -0
- package/src/lib/global-registry.ts +117 -0
- package/src/lib/parser.ts +794 -0
- package/src/lib/path-resolver.ts +16 -0
- package/src/lib/run-cache.ts +404 -0
- package/src/lib/server-init.ts +226 -0
- package/src/lib/services/__tests__/run-query-service.test.ts +819 -0
- package/src/lib/services/run-query-service.ts +286 -0
- package/src/lib/source-discovery.ts +216 -0
- package/src/lib/utils.ts +103 -0
- package/src/lib/watcher.ts +265 -0
- package/src/test/fixtures.ts +269 -0
- package/src/test/mocks/handlers.ts +110 -0
- package/src/test/mocks/server.ts +17 -0
- package/src/test/setup.ts +200 -0
- package/src/test/test-utils.tsx +36 -0
- package/src/types/.gitkeep +0 -0
- package/src/types/breakpoint.ts +17 -0
- package/src/types/index.ts +214 -0
- package/tsconfig.json +50 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
|
|
4
|
+
export interface Shortcut {
|
|
5
|
+
key: string;
|
|
6
|
+
ctrl?: boolean;
|
|
7
|
+
shift?: boolean;
|
|
8
|
+
action: () => void;
|
|
9
|
+
description: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useKeyboard(shortcuts: Shortcut[]) {
|
|
13
|
+
const shortcutsRef = useRef(shortcuts);
|
|
14
|
+
shortcutsRef.current = shortcuts;
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handler = (e: KeyboardEvent) => {
|
|
18
|
+
// Don't fire in input/textarea/select
|
|
19
|
+
const target = e.target as HTMLElement;
|
|
20
|
+
if (
|
|
21
|
+
target.tagName === "INPUT" ||
|
|
22
|
+
target.tagName === "TEXTAREA" ||
|
|
23
|
+
target.tagName === "SELECT" ||
|
|
24
|
+
target.isContentEditable
|
|
25
|
+
) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const shortcut of shortcutsRef.current) {
|
|
30
|
+
if (
|
|
31
|
+
e.key === shortcut.key &&
|
|
32
|
+
!!e.ctrlKey === !!shortcut.ctrl &&
|
|
33
|
+
!!e.shiftKey === !!shortcut.shift
|
|
34
|
+
) {
|
|
35
|
+
e.preventDefault();
|
|
36
|
+
shortcut.action();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
window.addEventListener("keydown", handler);
|
|
43
|
+
return () => window.removeEventListener("keydown", handler);
|
|
44
|
+
}, []);
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
export interface AppNotification {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
body: string;
|
|
8
|
+
type: "success" | "error" | "warning" | "info";
|
|
9
|
+
timestamp: number;
|
|
10
|
+
href?: string;
|
|
11
|
+
/** When true the notification will not auto-dismiss and must be closed manually or resolved externally. */
|
|
12
|
+
persistent?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useNotifications() {
|
|
16
|
+
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
|
17
|
+
const [permission, setPermission] =
|
|
18
|
+
useState<NotificationPermission>("default");
|
|
19
|
+
const counterRef = useRef(0);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (typeof window !== "undefined" && "Notification" in window) {
|
|
23
|
+
setPermission(Notification.permission);
|
|
24
|
+
}
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const requestPermission = useCallback(async () => {
|
|
28
|
+
if (typeof window === "undefined" || !("Notification" in window)) return;
|
|
29
|
+
const result = await Notification.requestPermission();
|
|
30
|
+
setPermission(result);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const notify = useCallback(
|
|
34
|
+
(
|
|
35
|
+
title: string,
|
|
36
|
+
body: string,
|
|
37
|
+
type: AppNotification["type"] = "info",
|
|
38
|
+
options?: { href?: string; persistent?: boolean },
|
|
39
|
+
) => {
|
|
40
|
+
const id = `notif-${++counterRef.current}-${Date.now()}`;
|
|
41
|
+
|
|
42
|
+
// In-app toast
|
|
43
|
+
const notification: AppNotification = {
|
|
44
|
+
id,
|
|
45
|
+
title,
|
|
46
|
+
body,
|
|
47
|
+
type,
|
|
48
|
+
timestamp: Date.now(),
|
|
49
|
+
href: options?.href,
|
|
50
|
+
persistent: options?.persistent,
|
|
51
|
+
};
|
|
52
|
+
setNotifications((prev) => [...prev, notification]);
|
|
53
|
+
|
|
54
|
+
// Auto-dismiss after 5 seconds (skip for persistent notifications like breakpoints)
|
|
55
|
+
if (!options?.persistent) {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
58
|
+
}, 5000);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Browser notification when tab is hidden
|
|
62
|
+
if (
|
|
63
|
+
permission === "granted" &&
|
|
64
|
+
typeof window !== "undefined" &&
|
|
65
|
+
document.hidden
|
|
66
|
+
) {
|
|
67
|
+
try {
|
|
68
|
+
new Notification(title, { body, tag: id });
|
|
69
|
+
} catch {
|
|
70
|
+
// Silent fail for environments that don't support Notification constructor
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[permission],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const dismiss = useCallback((id: string) => {
|
|
78
|
+
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return { notifications, notify, dismiss, requestPermission, permission };
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useCallback, useLayoutEffect, useEffect } from "react";
|
|
3
|
+
|
|
4
|
+
const NAMESPACE = "observer:";
|
|
5
|
+
|
|
6
|
+
// useLayoutEffect on client (runs before paint → no flash),
|
|
7
|
+
// useEffect on server (suppresses Next.js SSR warning).
|
|
8
|
+
const useIsomorphicLayoutEffect =
|
|
9
|
+
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Custom hook that wraps useState with localStorage persistence.
|
|
13
|
+
* Values are serialized with JSON.stringify/parse and namespaced
|
|
14
|
+
* under the "observer:" prefix to avoid collisions.
|
|
15
|
+
*
|
|
16
|
+
* Hydration-safe: first render uses defaultValue (matching SSR),
|
|
17
|
+
* then useLayoutEffect reads localStorage before the browser paints
|
|
18
|
+
* so the persisted value appears with no visible flash.
|
|
19
|
+
*/
|
|
20
|
+
export function usePersistedState<T>(
|
|
21
|
+
key: string,
|
|
22
|
+
defaultValue: T
|
|
23
|
+
): [T, (value: T | ((prev: T) => T)) => void] {
|
|
24
|
+
const prefixedKey = key.startsWith(NAMESPACE) ? key : `${NAMESPACE}${key}`;
|
|
25
|
+
|
|
26
|
+
// Always start with defaultValue to match SSR output (hydration-safe).
|
|
27
|
+
const [state, setState] = useState<T>(defaultValue);
|
|
28
|
+
|
|
29
|
+
// Read localStorage before paint — avoids both hydration mismatch and flash.
|
|
30
|
+
useIsomorphicLayoutEffect(() => {
|
|
31
|
+
try {
|
|
32
|
+
const stored = window.localStorage.getItem(prefixedKey);
|
|
33
|
+
if (stored !== null) {
|
|
34
|
+
const parsed = JSON.parse(stored) as T;
|
|
35
|
+
setState(parsed);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// localStorage unavailable — keep default
|
|
39
|
+
}
|
|
40
|
+
}, [prefixedKey]);
|
|
41
|
+
|
|
42
|
+
const setPersistedState = useCallback(
|
|
43
|
+
(value: T | ((prev: T) => T)) => {
|
|
44
|
+
setState((prev) => {
|
|
45
|
+
const next = typeof value === "function" ? (value as (prev: T) => T)(prev) : value;
|
|
46
|
+
try {
|
|
47
|
+
if (typeof window !== "undefined") {
|
|
48
|
+
window.localStorage.setItem(prefixedKey, JSON.stringify(next));
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// localStorage may be full or blocked — silently ignore
|
|
52
|
+
}
|
|
53
|
+
return next;
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
[prefixedKey]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return [state, setPersistedState];
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
3
|
+
import { resilientFetch } from "@/lib/fetcher";
|
|
4
|
+
|
|
5
|
+
interface UsePollingOptions {
|
|
6
|
+
interval?: number;
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function usePolling<T>(
|
|
11
|
+
url: string,
|
|
12
|
+
options: UsePollingOptions = {}
|
|
13
|
+
): { data: T | null; loading: boolean; error: string | null; refresh: () => void } {
|
|
14
|
+
const { interval = 2000, enabled = true } = options;
|
|
15
|
+
const [data, setData] = useState<T | null>(null);
|
|
16
|
+
const [loading, setLoading] = useState(enabled && !!url);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const mountedRef = useRef(true);
|
|
19
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
20
|
+
|
|
21
|
+
const fetchData = useCallback(async () => {
|
|
22
|
+
if (!url) return;
|
|
23
|
+
abortRef.current?.abort();
|
|
24
|
+
abortRef.current = new AbortController();
|
|
25
|
+
const result = await resilientFetch<T>(url, { signal: abortRef.current.signal });
|
|
26
|
+
if (!result.ok) {
|
|
27
|
+
if (result.error.isAborted) return;
|
|
28
|
+
if (mountedRef.current) {
|
|
29
|
+
setError(result.error.message);
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (mountedRef.current) {
|
|
35
|
+
setData(result.data);
|
|
36
|
+
setError(null);
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}, [url]);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
mountedRef.current = true;
|
|
43
|
+
if (!enabled || !url) {
|
|
44
|
+
setLoading(false);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setLoading(true);
|
|
49
|
+
fetchData();
|
|
50
|
+
const id = setInterval(fetchData, interval);
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
mountedRef.current = false;
|
|
54
|
+
abortRef.current?.abort();
|
|
55
|
+
clearInterval(id);
|
|
56
|
+
};
|
|
57
|
+
}, [fetchData, interval, enabled, url]);
|
|
58
|
+
|
|
59
|
+
return { data, loading, error, refresh: fetchData };
|
|
60
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useSmartPolling } from './use-smart-polling';
|
|
3
|
+
import { Run } from '@/types';
|
|
4
|
+
|
|
5
|
+
interface ProjectRunsResponse {
|
|
6
|
+
runs: Run[];
|
|
7
|
+
totalCount: number;
|
|
8
|
+
project: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UseProjectRunsOptions {
|
|
12
|
+
limit?: number;
|
|
13
|
+
offset?: number;
|
|
14
|
+
search?: string;
|
|
15
|
+
status?: string;
|
|
16
|
+
sort?: 'status' | 'activity';
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useProjectRuns(
|
|
21
|
+
projectName: string,
|
|
22
|
+
options: UseProjectRunsOptions = {}
|
|
23
|
+
) {
|
|
24
|
+
const { limit = 10, offset = 0, search = '', status = '', sort = 'status', enabled = true } = options;
|
|
25
|
+
const params = new URLSearchParams();
|
|
26
|
+
params.set('project', projectName);
|
|
27
|
+
params.set('limit', String(limit));
|
|
28
|
+
params.set('offset', String(offset));
|
|
29
|
+
if (search) params.set('search', search);
|
|
30
|
+
if (status) params.set('status', status);
|
|
31
|
+
if (sort && sort !== 'status') params.set('sort', sort);
|
|
32
|
+
|
|
33
|
+
const url = `/api/runs?${params.toString()}`;
|
|
34
|
+
|
|
35
|
+
const { data, loading, error, refresh } = useSmartPolling<ProjectRunsResponse>(
|
|
36
|
+
url,
|
|
37
|
+
{
|
|
38
|
+
interval: 5000,
|
|
39
|
+
sseFilter: () => true, // Any run update could affect this project
|
|
40
|
+
enabled
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
runs: enabled && data ? data.runs : [],
|
|
46
|
+
totalCount: enabled && data ? data.totalCount : 0,
|
|
47
|
+
loading,
|
|
48
|
+
error,
|
|
49
|
+
refresh
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useSmartPolling } from './use-smart-polling';
|
|
3
|
+
import { ProjectSummary } from '@/types';
|
|
4
|
+
|
|
5
|
+
interface ProjectsResponse {
|
|
6
|
+
projects: ProjectSummary[];
|
|
7
|
+
recentCompletionWindowMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useProjects(interval: number = 5000, suppressSseRefetch: boolean = false) {
|
|
11
|
+
const { data, loading, error, refresh } = useSmartPolling<ProjectsResponse>(
|
|
12
|
+
'/api/runs?mode=projects',
|
|
13
|
+
{
|
|
14
|
+
interval,
|
|
15
|
+
sseFilter: (event) => event.type === 'update' || event.type === 'new-run',
|
|
16
|
+
suppressSseRefetch,
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
return {
|
|
20
|
+
projects: data?.projects || [],
|
|
21
|
+
recentCompletionWindowMs: data?.recentCompletionWindowMs ?? 14400000,
|
|
22
|
+
loading,
|
|
23
|
+
error,
|
|
24
|
+
refresh
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useMemo, useCallback, useRef } from "react";
|
|
3
|
+
import { useProjects } from "./use-projects";
|
|
4
|
+
import { useBatchedUpdates, type CatchUpState } from "./use-batched-updates";
|
|
5
|
+
import { usePersistedState } from "./use-persisted-state";
|
|
6
|
+
import type { RunStatus, ProjectSummary, BreakpointRunInfo } from "@/types";
|
|
7
|
+
import type { ExecutiveSummaryMetrics } from "@/components/dashboard/executive-summary-banner";
|
|
8
|
+
|
|
9
|
+
/** Aggregated KPI metrics across all projects. */
|
|
10
|
+
export interface DashboardMetrics {
|
|
11
|
+
totalRuns: number;
|
|
12
|
+
activeRuns: number;
|
|
13
|
+
completedRuns: number;
|
|
14
|
+
failedRuns: number;
|
|
15
|
+
staleRuns: number;
|
|
16
|
+
totalTasks: number;
|
|
17
|
+
completedTasks: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DashboardSortMode = "status" | "activity";
|
|
21
|
+
export type DashboardStatusFilter = RunStatus | "all" | "stale";
|
|
22
|
+
|
|
23
|
+
export interface UseRunDashboardReturn {
|
|
24
|
+
// Data
|
|
25
|
+
projects: ProjectSummary[];
|
|
26
|
+
loading: boolean;
|
|
27
|
+
error: string | null | undefined;
|
|
28
|
+
metrics: DashboardMetrics;
|
|
29
|
+
allBreakpointRuns: BreakpointRunInfo[];
|
|
30
|
+
summaryMetrics: ExecutiveSummaryMetrics;
|
|
31
|
+
bannerFingerprint: string;
|
|
32
|
+
bannerDismissed: boolean;
|
|
33
|
+
filterCounts: Record<DashboardStatusFilter, number>;
|
|
34
|
+
filteredProjects: ProjectSummary[];
|
|
35
|
+
activeProjects: ProjectSummary[];
|
|
36
|
+
historyProjects: ProjectSummary[];
|
|
37
|
+
|
|
38
|
+
// State
|
|
39
|
+
statusFilter: DashboardStatusFilter;
|
|
40
|
+
sortMode: DashboardSortMode;
|
|
41
|
+
historyCollapsed: boolean;
|
|
42
|
+
cardStatusFilter: RunStatus | "all";
|
|
43
|
+
hasStaleRuns: boolean;
|
|
44
|
+
|
|
45
|
+
// Catch-up mode
|
|
46
|
+
catchUp: CatchUpState;
|
|
47
|
+
|
|
48
|
+
// Actions
|
|
49
|
+
setStatusFilter: (value: DashboardStatusFilter) => void;
|
|
50
|
+
setSortMode: (value: DashboardSortMode | ((prev: DashboardSortMode) => DashboardSortMode)) => void;
|
|
51
|
+
setHistoryCollapsed: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
52
|
+
setDismissedFingerprint: (value: string | null | ((prev: string | null) => string | null)) => void;
|
|
53
|
+
toggleMetricFilter: (filter: DashboardStatusFilter) => void;
|
|
54
|
+
handleHideProject: (projectName: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useRunDashboard(): UseRunDashboardReturn {
|
|
58
|
+
// Use a ref to bridge the circular dependency between useBatchedUpdates.onFlush
|
|
59
|
+
// and the refresh function returned by useProjects.
|
|
60
|
+
const refreshRef = useRef<() => void>(() => {});
|
|
61
|
+
|
|
62
|
+
// Monitor SSE event rate and activate catch-up mode during bursts.
|
|
63
|
+
// When catch-up is active, useProjects suppresses SSE-triggered refetches
|
|
64
|
+
// so the UI stays calm until the user clicks "refresh now" or the burst subsides.
|
|
65
|
+
const sseFilter = useCallback(
|
|
66
|
+
(event: { type: string }) => event.type === "update" || event.type === "new-run",
|
|
67
|
+
[]
|
|
68
|
+
);
|
|
69
|
+
const catchUp = useBatchedUpdates({
|
|
70
|
+
sseFilter,
|
|
71
|
+
onFlush: () => refreshRef.current(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const { projects, recentCompletionWindowMs, loading, error, refresh } = useProjects(
|
|
75
|
+
5000,
|
|
76
|
+
catchUp.active
|
|
77
|
+
);
|
|
78
|
+
refreshRef.current = refresh;
|
|
79
|
+
|
|
80
|
+
const [statusFilter, setStatusFilter] = useState<DashboardStatusFilter>("all");
|
|
81
|
+
const [sortMode, setSortMode] = usePersistedState<DashboardSortMode>("observer:sort-mode", "status");
|
|
82
|
+
const [dismissedFingerprint, setDismissedFingerprint] = usePersistedState<string | null>("banner-dismissed-fingerprint", null);
|
|
83
|
+
|
|
84
|
+
// Toggle filter from metric tile: clicking active filter clears it
|
|
85
|
+
const toggleMetricFilter = useCallback((filter: DashboardStatusFilter) => {
|
|
86
|
+
setStatusFilter((prev) => (prev === filter ? "all" : filter));
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const handleHideProject = useCallback((_projectName: string) => {
|
|
90
|
+
refresh();
|
|
91
|
+
}, [refresh]);
|
|
92
|
+
|
|
93
|
+
// Aggregate metrics across all projects
|
|
94
|
+
const metrics = useMemo<DashboardMetrics>(() => {
|
|
95
|
+
const totalRuns = projects.reduce((s, p) => s + p.totalRuns, 0);
|
|
96
|
+
const activeRuns = projects.reduce((s, p) => s + p.activeRuns, 0);
|
|
97
|
+
const completedRuns = projects.reduce((s, p) => s + p.completedRuns, 0);
|
|
98
|
+
const failedRuns = projects.reduce((s, p) => s + p.failedRuns, 0);
|
|
99
|
+
const staleRuns = projects.reduce((s, p) => s + p.staleRuns, 0);
|
|
100
|
+
const totalTasks = projects.reduce((s, p) => s + p.totalTasks, 0);
|
|
101
|
+
const completedTasks = projects.reduce((s, p) => s + p.completedTasksAggregate, 0);
|
|
102
|
+
return { totalRuns, activeRuns, completedRuns, failedRuns, staleRuns, totalTasks, completedTasks };
|
|
103
|
+
}, [projects]);
|
|
104
|
+
|
|
105
|
+
// Collect all breakpoint runs across all projects
|
|
106
|
+
const allBreakpointRuns = useMemo<BreakpointRunInfo[]>(() => {
|
|
107
|
+
return projects.flatMap((p) => p.breakpointRuns ?? []);
|
|
108
|
+
}, [projects]);
|
|
109
|
+
|
|
110
|
+
// Executive summary metrics for the banner
|
|
111
|
+
const summaryMetrics = useMemo<ExecutiveSummaryMetrics>(() => ({
|
|
112
|
+
totalProjects: projects.length,
|
|
113
|
+
activeRuns: metrics.activeRuns,
|
|
114
|
+
failedRuns: metrics.failedRuns,
|
|
115
|
+
completedRuns: metrics.completedRuns,
|
|
116
|
+
staleRuns: metrics.staleRuns,
|
|
117
|
+
pendingBreakpoints: projects.reduce((s, p) => s + p.pendingBreakpoints, 0),
|
|
118
|
+
}), [projects, metrics]);
|
|
119
|
+
|
|
120
|
+
// Fingerprint for banner dismiss
|
|
121
|
+
const bannerFingerprint = `${summaryMetrics.failedRuns}-${summaryMetrics.staleRuns}-${summaryMetrics.pendingBreakpoints}`;
|
|
122
|
+
const bannerDismissed = dismissedFingerprint === bannerFingerprint;
|
|
123
|
+
|
|
124
|
+
const filterCounts = useMemo(() => {
|
|
125
|
+
return {
|
|
126
|
+
all: metrics.totalRuns,
|
|
127
|
+
waiting: metrics.activeRuns,
|
|
128
|
+
stale: metrics.staleRuns,
|
|
129
|
+
completed: metrics.completedRuns,
|
|
130
|
+
failed: metrics.failedRuns,
|
|
131
|
+
pending: 0,
|
|
132
|
+
} as Record<DashboardStatusFilter, number>;
|
|
133
|
+
}, [metrics]);
|
|
134
|
+
|
|
135
|
+
// Filter projects by status counts
|
|
136
|
+
const filteredProjects = useMemo(() => {
|
|
137
|
+
if (statusFilter === "all") return projects;
|
|
138
|
+
if (statusFilter === "stale") return projects.filter((p) => p.staleRuns > 0);
|
|
139
|
+
return projects.filter((project) => {
|
|
140
|
+
if (statusFilter === "waiting") return project.activeRuns > 0;
|
|
141
|
+
if (statusFilter === "completed") return project.completedRuns > 0;
|
|
142
|
+
if (statusFilter === "failed") return project.failedRuns > 0;
|
|
143
|
+
return false;
|
|
144
|
+
});
|
|
145
|
+
}, [projects, statusFilter]);
|
|
146
|
+
|
|
147
|
+
// Determine the status filter to pass to ProjectHealthCard
|
|
148
|
+
const cardStatusFilter: RunStatus | "all" = statusFilter === "stale" ? "all" : statusFilter;
|
|
149
|
+
|
|
150
|
+
// Split filtered projects into sections based on sort mode
|
|
151
|
+
const { activeProjects, historyProjects } = useMemo(() => {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
if (sortMode === "activity") {
|
|
154
|
+
const twentyFourHours = 24 * 60 * 60 * 1000;
|
|
155
|
+
const recent = filteredProjects.filter((p) =>
|
|
156
|
+
now - new Date(p.latestUpdate).getTime() < twentyFourHours
|
|
157
|
+
);
|
|
158
|
+
const earlier = filteredProjects.filter((p) =>
|
|
159
|
+
now - new Date(p.latestUpdate).getTime() >= twentyFourHours
|
|
160
|
+
);
|
|
161
|
+
return { activeProjects: recent, historyProjects: earlier };
|
|
162
|
+
}
|
|
163
|
+
const active = filteredProjects.filter((p) =>
|
|
164
|
+
p.activeRuns > 0 || p.staleRuns > 0 ||
|
|
165
|
+
(now - new Date(p.latestUpdate).getTime() < recentCompletionWindowMs)
|
|
166
|
+
);
|
|
167
|
+
const history = filteredProjects.filter((p) =>
|
|
168
|
+
p.activeRuns === 0 && p.staleRuns === 0 &&
|
|
169
|
+
(now - new Date(p.latestUpdate).getTime() >= recentCompletionWindowMs)
|
|
170
|
+
);
|
|
171
|
+
return { activeProjects: active, historyProjects: history };
|
|
172
|
+
}, [filteredProjects, recentCompletionWindowMs, sortMode]);
|
|
173
|
+
|
|
174
|
+
const [historyCollapsed, setHistoryCollapsed] = usePersistedState(
|
|
175
|
+
"observer:history-collapsed",
|
|
176
|
+
historyProjects.length > 5
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const hasStaleRuns = metrics.staleRuns > 0;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
projects,
|
|
183
|
+
loading,
|
|
184
|
+
error,
|
|
185
|
+
metrics,
|
|
186
|
+
allBreakpointRuns,
|
|
187
|
+
summaryMetrics,
|
|
188
|
+
bannerFingerprint,
|
|
189
|
+
bannerDismissed,
|
|
190
|
+
filterCounts,
|
|
191
|
+
filteredProjects,
|
|
192
|
+
activeProjects,
|
|
193
|
+
historyProjects,
|
|
194
|
+
statusFilter,
|
|
195
|
+
sortMode,
|
|
196
|
+
historyCollapsed,
|
|
197
|
+
cardStatusFilter,
|
|
198
|
+
hasStaleRuns,
|
|
199
|
+
catchUp,
|
|
200
|
+
setStatusFilter,
|
|
201
|
+
setSortMode,
|
|
202
|
+
setHistoryCollapsed,
|
|
203
|
+
setDismissedFingerprint,
|
|
204
|
+
toggleMetricFilter,
|
|
205
|
+
handleHideProject,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo, useRef } from "react";
|
|
3
|
+
import { useSmartPolling } from "./use-smart-polling";
|
|
4
|
+
import type { RunDetailResponse, TaskDetailResponse, RunStatus } from "@/types";
|
|
5
|
+
|
|
6
|
+
// Poll intervals by run status:
|
|
7
|
+
// - Active/waiting runs change frequently, poll every 3s
|
|
8
|
+
// - Completed/failed runs are static, poll every 30s (just in case of late events)
|
|
9
|
+
const POLL_ACTIVE = 3000;
|
|
10
|
+
const POLL_COMPLETED = 30000;
|
|
11
|
+
|
|
12
|
+
function getIntervalForStatus(status: RunStatus | undefined): number {
|
|
13
|
+
if (!status) return POLL_ACTIVE;
|
|
14
|
+
return status === "completed" || status === "failed"
|
|
15
|
+
? POLL_COMPLETED
|
|
16
|
+
: POLL_ACTIVE;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useRunDetail(runId: string, intervalOverride?: number) {
|
|
20
|
+
// Track last known run status to adapt poll interval.
|
|
21
|
+
// useRef avoids triggering re-render; the interval change takes effect
|
|
22
|
+
// on the next useSmartPolling dependency cycle.
|
|
23
|
+
const lastStatusRef = useRef<RunStatus | undefined>(undefined);
|
|
24
|
+
|
|
25
|
+
const adaptiveInterval = intervalOverride ?? getIntervalForStatus(lastStatusRef.current);
|
|
26
|
+
|
|
27
|
+
const { data, loading, error, refresh } = useSmartPolling<RunDetailResponse>(
|
|
28
|
+
`/api/runs/${runId}?maxEvents=50`,
|
|
29
|
+
{
|
|
30
|
+
interval: adaptiveInterval,
|
|
31
|
+
sseFilter: (event) =>
|
|
32
|
+
event.runId === runId || (event.runIds?.includes(runId) ?? false)
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const run = data?.run || null;
|
|
37
|
+
|
|
38
|
+
// Update status ref so next render picks up the adaptive interval.
|
|
39
|
+
// When status transitions (e.g. "waiting" -> "completed"), the interval
|
|
40
|
+
// changes from 3s to 30s, and useSmartPolling restarts its timer.
|
|
41
|
+
if (run && run.status !== lastStatusRef.current) {
|
|
42
|
+
lastStatusRef.current = run.status;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detect if any breakpoint tasks are waiting for approval
|
|
46
|
+
const hasBreakpointWaiting = useMemo(() => {
|
|
47
|
+
if (!run) return false;
|
|
48
|
+
return run.tasks.some(
|
|
49
|
+
(t) => t.kind === "breakpoint" && t.status === "requested"
|
|
50
|
+
);
|
|
51
|
+
}, [run]);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
run,
|
|
55
|
+
loading,
|
|
56
|
+
error,
|
|
57
|
+
refresh,
|
|
58
|
+
hasBreakpointWaiting,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useTaskDetail(runId: string, effectId: string | null) {
|
|
63
|
+
const { data, loading, error } = useSmartPolling<TaskDetailResponse>(
|
|
64
|
+
effectId ? `/api/runs/${runId}/tasks/${effectId}` : "",
|
|
65
|
+
{
|
|
66
|
+
enabled: !!effectId,
|
|
67
|
+
interval: 5000,
|
|
68
|
+
sseFilter: (event) =>
|
|
69
|
+
event.runId === runId || (event.runIds?.includes(runId) ?? false),
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
return {
|
|
73
|
+
task: data?.task || null,
|
|
74
|
+
loading,
|
|
75
|
+
error,
|
|
76
|
+
};
|
|
77
|
+
}
|