@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,301 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useTransition } from "react";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
import { Hand, AlertTriangle, CheckCircle2, Check, X } from "lucide-react";
|
|
6
|
+
import { approveBreakpoint } from "@/app/actions/approve-breakpoint";
|
|
7
|
+
import type { BreakpointRunInfo } from "@/types";
|
|
8
|
+
|
|
9
|
+
interface ResolvedEntry {
|
|
10
|
+
bp: BreakpointRunInfo;
|
|
11
|
+
resolvedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RESOLVED_DISPLAY_MS = 20000; // 20 seconds
|
|
15
|
+
const STALENESS_THRESHOLD_MS = 120000; // 2 minutes — if a breakpoint has been shown
|
|
16
|
+
// continuously for this long, show a hint
|
|
17
|
+
const DISMISSED_KEY = "observer:dismissed-breakpoints";
|
|
18
|
+
|
|
19
|
+
/** Inline approve button for a single breakpoint in the dashboard banner. */
|
|
20
|
+
function BreakpointBannerItem({ bp, stale, onDismiss }: { bp: BreakpointRunInfo; stale: boolean; onDismiss?: () => void }) {
|
|
21
|
+
const [isPending, startTransition] = useTransition();
|
|
22
|
+
const [result, setResult] = useState<{ ok: boolean; msg?: string } | null>(null);
|
|
23
|
+
|
|
24
|
+
const handleApprove = (e: React.MouseEvent) => {
|
|
25
|
+
e.preventDefault(); // Don't navigate via the Link
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
startTransition(async () => {
|
|
28
|
+
const res = await approveBreakpoint(bp.runId, bp.effectId, "Approved from dashboard");
|
|
29
|
+
setResult(res.success ? { ok: true } : { ok: false, msg: res.error });
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (result?.ok) {
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn(
|
|
36
|
+
"group relative flex items-center gap-3 px-4 py-3 rounded-lg",
|
|
37
|
+
"bg-success-muted border border-success/30",
|
|
38
|
+
)}>
|
|
39
|
+
<CheckCircle2 className="h-5 w-5 text-success shrink-0" />
|
|
40
|
+
<span className="text-sm text-success font-medium">Approved — {bp.projectName}</span>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn(
|
|
47
|
+
"group relative flex items-center gap-3 px-4 py-3 rounded-lg",
|
|
48
|
+
"bg-warning-muted border border-warning/30",
|
|
49
|
+
"shadow-breakpoint-glow animate-breakpoint-glow",
|
|
50
|
+
stale && "opacity-70"
|
|
51
|
+
)}>
|
|
52
|
+
<Link href={`/runs/${bp.runId}`} className="flex items-center gap-3 flex-1 min-w-0">
|
|
53
|
+
<div className="relative shrink-0">
|
|
54
|
+
<Hand className="h-5 w-5 text-warning animate-pulse-dot" />
|
|
55
|
+
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-warning animate-ping" />
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex-1 min-w-0">
|
|
58
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
59
|
+
<AlertTriangle className="h-3 w-3 text-warning shrink-0" />
|
|
60
|
+
<span className="text-xs font-bold text-warning uppercase tracking-wider">
|
|
61
|
+
Approval Needed
|
|
62
|
+
</span>
|
|
63
|
+
{stale && (
|
|
64
|
+
<span className="text-xs text-foreground-muted italic" data-testid="staleness-indicator">
|
|
65
|
+
(checking...)
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
<span className="text-xs text-foreground-muted font-medium">
|
|
69
|
+
{bp.projectName}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="font-mono text-xs text-info">
|
|
72
|
+
{bp.runId.slice(0, 8)}
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
<p className="text-sm text-foreground truncate">
|
|
76
|
+
{bp.breakpointQuestion}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</Link>
|
|
80
|
+
<button
|
|
81
|
+
onClick={handleApprove}
|
|
82
|
+
disabled={isPending || !bp.effectId}
|
|
83
|
+
className={cn(
|
|
84
|
+
"shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold",
|
|
85
|
+
"bg-success/20 text-success border border-success/30",
|
|
86
|
+
"hover:bg-success/30 hover:border-success/50",
|
|
87
|
+
"disabled:opacity-50 disabled:cursor-not-allowed",
|
|
88
|
+
"transition-colors"
|
|
89
|
+
)}
|
|
90
|
+
title="Approve this breakpoint"
|
|
91
|
+
>
|
|
92
|
+
<Check className="h-3.5 w-3.5" />
|
|
93
|
+
{isPending ? "Approving..." : "Approve"}
|
|
94
|
+
</button>
|
|
95
|
+
{stale && onDismiss && (
|
|
96
|
+
<button
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
onDismiss();
|
|
101
|
+
}}
|
|
102
|
+
className={cn(
|
|
103
|
+
"shrink-0 p-1 rounded text-foreground-muted/50",
|
|
104
|
+
"hover:text-foreground-muted hover:bg-foreground-muted/10",
|
|
105
|
+
"transition-colors"
|
|
106
|
+
)}
|
|
107
|
+
title="Dismiss this stale breakpoint"
|
|
108
|
+
data-testid="dismiss-breakpoint"
|
|
109
|
+
>
|
|
110
|
+
<X className="h-3.5 w-3.5" />
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
{result && !result.ok && (
|
|
114
|
+
<span className="text-xs text-error ml-2">{result.msg}</span>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface BreakpointBannerProps {
|
|
121
|
+
breakpointRuns: BreakpointRunInfo[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function BreakpointBanner({ breakpointRuns }: BreakpointBannerProps) {
|
|
125
|
+
const [resolvedEntries, setResolvedEntries] = useState<ResolvedEntry[]>([]);
|
|
126
|
+
const prevRunIdsRef = useRef<Map<string, BreakpointRunInfo>>(new Map());
|
|
127
|
+
|
|
128
|
+
// Track when each breakpoint entry was first seen (by runId).
|
|
129
|
+
// Entries are cleaned up when the breakpoint disappears from the list.
|
|
130
|
+
const firstSeenRef = useRef<Map<string, number>>(new Map());
|
|
131
|
+
|
|
132
|
+
// Dismissed stale breakpoints (client-side only, persisted in localStorage)
|
|
133
|
+
// Start with empty set to match SSR, then read localStorage before paint.
|
|
134
|
+
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
|
135
|
+
|
|
136
|
+
useLayoutEffect(() => {
|
|
137
|
+
try {
|
|
138
|
+
const raw = localStorage.getItem(DISMISSED_KEY);
|
|
139
|
+
if (raw) {
|
|
140
|
+
setDismissedIds(new Set(JSON.parse(raw) as string[]));
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// localStorage unavailable
|
|
144
|
+
}
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const dismissBreakpoint = useCallback((runId: string) => {
|
|
148
|
+
setDismissedIds((prev) => {
|
|
149
|
+
const next = new Set(prev);
|
|
150
|
+
next.add(runId);
|
|
151
|
+
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next])); } catch { /* noop */ }
|
|
152
|
+
return next;
|
|
153
|
+
});
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
// Clean up dismissed IDs that are no longer in the breakpointRuns list
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const currentIds = new Set(breakpointRuns.map((bp) => bp.runId));
|
|
159
|
+
setDismissedIds((prev) => {
|
|
160
|
+
const cleaned = new Set([...prev].filter((id) => currentIds.has(id)));
|
|
161
|
+
if (cleaned.size !== prev.size) {
|
|
162
|
+
try { localStorage.setItem(DISMISSED_KEY, JSON.stringify([...cleaned])); } catch { /* noop */ }
|
|
163
|
+
return cleaned;
|
|
164
|
+
}
|
|
165
|
+
return prev;
|
|
166
|
+
});
|
|
167
|
+
}, [breakpointRuns]);
|
|
168
|
+
|
|
169
|
+
// State to trigger re-renders for staleness checks
|
|
170
|
+
const [, setStalenessTick] = useState(0);
|
|
171
|
+
|
|
172
|
+
// Detect resolved breakpoints: runs that were previously in the list but are now gone
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const currentIds = new Set(breakpointRuns.map((bp) => bp.runId));
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
|
|
177
|
+
const newlyResolved: ResolvedEntry[] = [];
|
|
178
|
+
for (const [runId, bp] of prevRunIdsRef.current) {
|
|
179
|
+
if (!currentIds.has(runId)) {
|
|
180
|
+
newlyResolved.push({ bp, resolvedAt: now });
|
|
181
|
+
// Clean up first-seen tracking for resolved breakpoints
|
|
182
|
+
firstSeenRef.current.delete(runId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (newlyResolved.length > 0) {
|
|
187
|
+
setResolvedEntries((prev) => [...prev, ...newlyResolved]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Record first-seen time for new breakpoints
|
|
191
|
+
for (const bp of breakpointRuns) {
|
|
192
|
+
if (!firstSeenRef.current.has(bp.runId)) {
|
|
193
|
+
firstSeenRef.current.set(bp.runId, now);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Update prev ref
|
|
198
|
+
prevRunIdsRef.current = new Map(breakpointRuns.map((bp) => [bp.runId, bp]));
|
|
199
|
+
}, [breakpointRuns]);
|
|
200
|
+
|
|
201
|
+
// Periodic tick to re-evaluate staleness (every 10s while breakpoints are active)
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (breakpointRuns.length === 0) return;
|
|
204
|
+
|
|
205
|
+
const timer = setInterval(() => {
|
|
206
|
+
setStalenessTick((t) => t + 1);
|
|
207
|
+
}, 10000);
|
|
208
|
+
|
|
209
|
+
return () => clearInterval(timer);
|
|
210
|
+
}, [breakpointRuns.length]);
|
|
211
|
+
|
|
212
|
+
// Helper: check if a breakpoint has been shown for longer than the staleness threshold
|
|
213
|
+
const isStale = useCallback((runId: string): boolean => {
|
|
214
|
+
const firstSeen = firstSeenRef.current.get(runId);
|
|
215
|
+
if (!firstSeen) return false;
|
|
216
|
+
return Date.now() - firstSeen > STALENESS_THRESHOLD_MS;
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
// Auto-cleanup expired resolved entries
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (resolvedEntries.length === 0) return;
|
|
222
|
+
|
|
223
|
+
const timer = setInterval(() => {
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
setResolvedEntries((prev) =>
|
|
226
|
+
prev.filter((entry) => now - entry.resolvedAt < RESOLVED_DISPLAY_MS)
|
|
227
|
+
);
|
|
228
|
+
}, 1000);
|
|
229
|
+
|
|
230
|
+
return () => clearInterval(timer);
|
|
231
|
+
}, [resolvedEntries.length]);
|
|
232
|
+
|
|
233
|
+
const visibleRuns = breakpointRuns.filter((bp) => !dismissedIds.has(bp.runId));
|
|
234
|
+
const hasWaiting = visibleRuns.length > 0;
|
|
235
|
+
const hasResolved = resolvedEntries.length > 0;
|
|
236
|
+
|
|
237
|
+
if (!hasWaiting && !hasResolved) return null;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div role="alert" aria-live="assertive" aria-atomic="true" className="flex flex-col gap-2 mb-6" data-testid="breakpoint-banner">
|
|
241
|
+
{/* Active breakpoints waiting */}
|
|
242
|
+
{visibleRuns.map((bp) => {
|
|
243
|
+
const stale = isStale(bp.runId);
|
|
244
|
+
return (
|
|
245
|
+
<BreakpointBannerItem
|
|
246
|
+
key={bp.runId}
|
|
247
|
+
bp={bp}
|
|
248
|
+
stale={stale}
|
|
249
|
+
onDismiss={() => dismissBreakpoint(bp.runId)}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
|
|
254
|
+
{/* Recently resolved breakpoints — green transient display */}
|
|
255
|
+
{resolvedEntries.map((entry) => (
|
|
256
|
+
<Link
|
|
257
|
+
key={`resolved-${entry.bp.runId}`}
|
|
258
|
+
href={`/runs/${entry.bp.runId}`}
|
|
259
|
+
className={cn(
|
|
260
|
+
"group relative flex items-center gap-3 px-4 py-3 rounded-lg",
|
|
261
|
+
"bg-success-muted border border-success/30",
|
|
262
|
+
"shadow-glow-success",
|
|
263
|
+
"hover:border-success/50",
|
|
264
|
+
"transition-colors cursor-pointer"
|
|
265
|
+
)}
|
|
266
|
+
>
|
|
267
|
+
<div className="relative shrink-0">
|
|
268
|
+
<CheckCircle2 className="h-5 w-5 text-success" />
|
|
269
|
+
</div>
|
|
270
|
+
<div className="flex-1 min-w-0">
|
|
271
|
+
<div className="flex items-center gap-2 mb-0.5">
|
|
272
|
+
<CheckCircle2 className="h-3 w-3 text-success shrink-0" />
|
|
273
|
+
<span className="text-xs font-bold text-success uppercase tracking-wider">
|
|
274
|
+
Approved
|
|
275
|
+
</span>
|
|
276
|
+
<span className="text-xs text-foreground-muted font-medium">
|
|
277
|
+
{entry.bp.projectName}
|
|
278
|
+
</span>
|
|
279
|
+
<span className="font-mono text-xs text-info">
|
|
280
|
+
{entry.bp.runId.slice(0, 8)}
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
<p className="text-sm text-foreground-muted truncate">
|
|
284
|
+
{entry.bp.breakpointQuestion}
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
</Link>
|
|
288
|
+
))}
|
|
289
|
+
|
|
290
|
+
{/* Summary count when multiple waiting breakpoints */}
|
|
291
|
+
{visibleRuns.length > 1 && (
|
|
292
|
+
<div className="flex items-center gap-2 px-3 py-1">
|
|
293
|
+
<Hand className="h-3.5 w-3.5 text-warning" />
|
|
294
|
+
<span className="text-xs font-semibold text-warning">
|
|
295
|
+
{visibleRuns.length} approvals pending
|
|
296
|
+
</span>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { RefreshCw, Inbox } from "lucide-react";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
import type { CatchUpState } from "@/hooks/use-batched-updates";
|
|
5
|
+
|
|
6
|
+
/** Lightweight snapshot of dashboard KPIs shown inside the catch-up banner. */
|
|
7
|
+
export interface CatchUpSummary {
|
|
8
|
+
failedRuns: number;
|
|
9
|
+
completedRuns: number;
|
|
10
|
+
pendingBreakpoints: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CatchUpBannerProps {
|
|
14
|
+
catchUp: CatchUpState;
|
|
15
|
+
/** Optional summary metrics to give the user quick context about what happened. */
|
|
16
|
+
summary?: CatchUpSummary;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds a concise, human-readable summary string from dashboard metrics.
|
|
21
|
+
* Returns null when there is nothing noteworthy to report.
|
|
22
|
+
*/
|
|
23
|
+
function buildSummaryText(summary: CatchUpSummary | undefined): string | null {
|
|
24
|
+
if (!summary) return null;
|
|
25
|
+
const parts: string[] = [];
|
|
26
|
+
if (summary.failedRuns > 0) {
|
|
27
|
+
parts.push(`${summary.failedRuns} failed`);
|
|
28
|
+
}
|
|
29
|
+
if (summary.pendingBreakpoints > 0) {
|
|
30
|
+
parts.push(`${summary.pendingBreakpoints} awaiting input`);
|
|
31
|
+
}
|
|
32
|
+
if (summary.completedRuns > 0) {
|
|
33
|
+
parts.push(`${summary.completedRuns} completed`);
|
|
34
|
+
}
|
|
35
|
+
if (parts.length === 0) return null;
|
|
36
|
+
return parts.join(", ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Subtle notification shown when the dashboard detects a burst of SSE updates
|
|
41
|
+
* (catch-up mode). Displays the number of buffered updates, an optional
|
|
42
|
+
* summary of what happened, and a "refresh now" button to immediately apply
|
|
43
|
+
* all pending changes.
|
|
44
|
+
*/
|
|
45
|
+
export function CatchUpBanner({ catchUp, summary }: CatchUpBannerProps) {
|
|
46
|
+
if (!catchUp.active) return null;
|
|
47
|
+
|
|
48
|
+
const summaryText = buildSummaryText(summary);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-testid="catch-up-banner"
|
|
53
|
+
className={cn(
|
|
54
|
+
"flex items-center gap-3 px-4 py-2.5 mb-4 rounded-lg",
|
|
55
|
+
"bg-info-muted border border-info/20",
|
|
56
|
+
"animate-in fade-in slide-in-from-top-2 duration-300"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<div className="rounded-md p-1.5 bg-info/10">
|
|
60
|
+
<Inbox className="h-4 w-4 text-info" />
|
|
61
|
+
</div>
|
|
62
|
+
<div className="flex-1 min-w-0">
|
|
63
|
+
<p className="text-sm text-foreground">
|
|
64
|
+
<span className="font-semibold tabular-nums">{catchUp.bufferedCount}</span>
|
|
65
|
+
{" "}runs updated while you were away
|
|
66
|
+
</p>
|
|
67
|
+
{summaryText && (
|
|
68
|
+
<p data-testid="catch-up-summary" className="text-xs text-foreground-muted mt-0.5 truncate">
|
|
69
|
+
{summaryText}
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<button
|
|
74
|
+
onClick={catchUp.flush}
|
|
75
|
+
data-testid="catch-up-refresh-btn"
|
|
76
|
+
className={cn(
|
|
77
|
+
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-semibold",
|
|
78
|
+
"bg-info/10 border border-info/20 text-info",
|
|
79
|
+
"hover:bg-info/20 transition-colors",
|
|
80
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-info/50"
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<RefreshCw className="h-3 w-3" />
|
|
84
|
+
Refresh now
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { CheckCircle2, AlertTriangle, XCircle, X } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/cn";
|
|
5
|
+
import type { RunStatus } from "@/types";
|
|
6
|
+
|
|
7
|
+
export interface ExecutiveSummaryMetrics {
|
|
8
|
+
totalProjects: number;
|
|
9
|
+
activeRuns: number;
|
|
10
|
+
failedRuns: number;
|
|
11
|
+
completedRuns: number;
|
|
12
|
+
staleRuns: number;
|
|
13
|
+
pendingBreakpoints: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type SeverityLevel = "healthy" | "amber" | "red";
|
|
17
|
+
|
|
18
|
+
interface SummaryIssue {
|
|
19
|
+
text: string;
|
|
20
|
+
filter: RunStatus | "stale" | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SummaryResult {
|
|
24
|
+
severity: SeverityLevel;
|
|
25
|
+
issues: SummaryIssue[];
|
|
26
|
+
icon: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function deriveSummary(m: ExecutiveSummaryMetrics): SummaryResult {
|
|
30
|
+
const issues: SummaryIssue[] = [];
|
|
31
|
+
let severity: SeverityLevel = "healthy";
|
|
32
|
+
|
|
33
|
+
// Red-level issues
|
|
34
|
+
if (m.failedRuns > 0) {
|
|
35
|
+
issues.push({
|
|
36
|
+
text: `${m.failedRuns} run${m.failedRuns !== 1 ? "s" : ""} failing`,
|
|
37
|
+
filter: "failed",
|
|
38
|
+
});
|
|
39
|
+
severity = "red";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Amber-level issues
|
|
43
|
+
if (m.pendingBreakpoints > 0) {
|
|
44
|
+
issues.push({
|
|
45
|
+
text: `${m.pendingBreakpoints} approval${m.pendingBreakpoints !== 1 ? "s" : ""} need${m.pendingBreakpoints === 1 ? "s" : ""} your attention`,
|
|
46
|
+
filter: "waiting",
|
|
47
|
+
});
|
|
48
|
+
if (severity !== "red") severity = "amber";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (m.staleRuns > 0) {
|
|
52
|
+
issues.push({
|
|
53
|
+
text: `${m.staleRuns} stale run${m.staleRuns !== 1 ? "s" : ""}`,
|
|
54
|
+
filter: "stale",
|
|
55
|
+
});
|
|
56
|
+
if (severity !== "red") severity = "amber";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Healthy
|
|
60
|
+
if (issues.length === 0) {
|
|
61
|
+
const projectLabel = m.totalProjects === 1 ? "project" : "projects";
|
|
62
|
+
const text =
|
|
63
|
+
m.activeRuns > 0
|
|
64
|
+
? `All ${m.totalProjects} ${projectLabel} healthy \u2014 ${m.activeRuns} run${m.activeRuns !== 1 ? "s" : ""} in progress`
|
|
65
|
+
: `All ${m.totalProjects} ${projectLabel} healthy`;
|
|
66
|
+
return {
|
|
67
|
+
severity: "healthy",
|
|
68
|
+
issues: [{ text, filter: null }],
|
|
69
|
+
icon: <CheckCircle2 className="h-4 w-4 shrink-0" aria-hidden="true" />,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const icon =
|
|
74
|
+
severity === "red" ? (
|
|
75
|
+
<XCircle className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
76
|
+
) : (
|
|
77
|
+
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { severity, issues, icon };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const severityStyles: Record<
|
|
84
|
+
SeverityLevel,
|
|
85
|
+
{ container: string; text: string; iconColor: string }
|
|
86
|
+
> = {
|
|
87
|
+
healthy: {
|
|
88
|
+
container:
|
|
89
|
+
"border-success/25 bg-success-muted shadow-neon-glow-success-sm",
|
|
90
|
+
text: "text-success",
|
|
91
|
+
iconColor: "text-success",
|
|
92
|
+
},
|
|
93
|
+
amber: {
|
|
94
|
+
container:
|
|
95
|
+
"border-warning/25 bg-warning-muted shadow-neon-glow-warning-sm",
|
|
96
|
+
text: "text-warning",
|
|
97
|
+
iconColor: "text-warning",
|
|
98
|
+
},
|
|
99
|
+
red: {
|
|
100
|
+
container: "border-error/25 bg-error-muted shadow-neon-glow-error-sm",
|
|
101
|
+
text: "text-error",
|
|
102
|
+
iconColor: "text-error",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface ExecutiveSummaryBannerProps {
|
|
107
|
+
metrics: ExecutiveSummaryMetrics;
|
|
108
|
+
onFilterChange?: (filter: RunStatus | "stale") => void;
|
|
109
|
+
dismissed?: boolean;
|
|
110
|
+
onDismiss?: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function ExecutiveSummaryBanner({
|
|
114
|
+
metrics,
|
|
115
|
+
onFilterChange,
|
|
116
|
+
dismissed,
|
|
117
|
+
onDismiss,
|
|
118
|
+
}: ExecutiveSummaryBannerProps) {
|
|
119
|
+
const summary = useMemo(() => deriveSummary(metrics), [metrics]);
|
|
120
|
+
const styles = severityStyles[summary.severity];
|
|
121
|
+
|
|
122
|
+
if (dismissed) return null;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
role="status"
|
|
127
|
+
aria-live="polite"
|
|
128
|
+
aria-atomic="true"
|
|
129
|
+
data-testid="executive-summary-banner"
|
|
130
|
+
className={cn(
|
|
131
|
+
"flex items-center gap-2.5 rounded-xl border px-4 py-3 mb-6 shadow-sm transition-all duration-300",
|
|
132
|
+
styles.container
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<span className={styles.iconColor}>{summary.icon}</span>
|
|
136
|
+
<p
|
|
137
|
+
className={cn(
|
|
138
|
+
"text-sm font-medium leading-snug flex-1",
|
|
139
|
+
styles.text
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{summary.issues.map((issue, i) => (
|
|
143
|
+
<span key={i}>
|
|
144
|
+
{i > 0 && ", "}
|
|
145
|
+
{issue.filter && onFilterChange ? (
|
|
146
|
+
<button
|
|
147
|
+
onClick={() => onFilterChange(issue.filter!)}
|
|
148
|
+
className="underline decoration-dotted underline-offset-2 hover:decoration-solid transition-all"
|
|
149
|
+
>
|
|
150
|
+
{issue.text}
|
|
151
|
+
</button>
|
|
152
|
+
) : (
|
|
153
|
+
issue.text
|
|
154
|
+
)}
|
|
155
|
+
</span>
|
|
156
|
+
))}
|
|
157
|
+
</p>
|
|
158
|
+
{summary.severity !== "healthy" && onDismiss && (
|
|
159
|
+
<button
|
|
160
|
+
data-testid="executive-summary-dismiss"
|
|
161
|
+
onClick={onDismiss}
|
|
162
|
+
className={cn(
|
|
163
|
+
"rounded-md p-1.5 min-h-[44px] min-w-[44px] flex items-center justify-center transition-colors",
|
|
164
|
+
styles.iconColor,
|
|
165
|
+
"opacity-60 hover:opacity-100"
|
|
166
|
+
)}
|
|
167
|
+
aria-label="Dismiss banner"
|
|
168
|
+
>
|
|
169
|
+
<X className="h-3.5 w-3.5" />
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|