@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,323 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Search, X, ArrowRight } from "lucide-react";
|
|
6
|
+
import { cn } from "@/lib/cn";
|
|
7
|
+
import { StatusBadge } from "@/components/shared/status-badge";
|
|
8
|
+
import { friendlyProcessName, formatShortId } from "@/lib/utils";
|
|
9
|
+
import type { Run } from "@/types";
|
|
10
|
+
|
|
11
|
+
interface SearchResult {
|
|
12
|
+
runs: Run[];
|
|
13
|
+
totalCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function GlobalSearch() {
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const [query, setQuery] = useState("");
|
|
19
|
+
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
20
|
+
const [results, setResults] = useState<Run[]>([]);
|
|
21
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
22
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
23
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
24
|
+
const [hasSearched, setHasSearched] = useState(false);
|
|
25
|
+
|
|
26
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
const listRef = useRef<HTMLUListElement>(null);
|
|
29
|
+
|
|
30
|
+
// Debounce the search query
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
setDebouncedQuery(query.trim());
|
|
34
|
+
}, 300);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [query]);
|
|
37
|
+
|
|
38
|
+
// Fetch results when debounced query changes
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!debouncedQuery) {
|
|
41
|
+
setResults([]);
|
|
42
|
+
setHasSearched(false);
|
|
43
|
+
setSelectedIndex(-1);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
|
|
49
|
+
async function fetchResults() {
|
|
50
|
+
setIsLoading(true);
|
|
51
|
+
try {
|
|
52
|
+
const params = new URLSearchParams({
|
|
53
|
+
search: debouncedQuery,
|
|
54
|
+
limit: "10",
|
|
55
|
+
});
|
|
56
|
+
const res = await fetch(`/api/runs?${params}`);
|
|
57
|
+
if (!res.ok) throw new Error("Search failed");
|
|
58
|
+
const data: SearchResult = await res.json();
|
|
59
|
+
if (!cancelled) {
|
|
60
|
+
setResults(data.runs ?? []);
|
|
61
|
+
setHasSearched(true);
|
|
62
|
+
setSelectedIndex(-1);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
if (!cancelled) {
|
|
66
|
+
setResults([]);
|
|
67
|
+
setHasSearched(true);
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
if (!cancelled) {
|
|
71
|
+
setIsLoading(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fetchResults();
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
};
|
|
80
|
+
}, [debouncedQuery]);
|
|
81
|
+
|
|
82
|
+
// Navigate to a run
|
|
83
|
+
const navigateToRun = useCallback(
|
|
84
|
+
(runId: string) => {
|
|
85
|
+
setIsOpen(false);
|
|
86
|
+
setQuery("");
|
|
87
|
+
setResults([]);
|
|
88
|
+
setHasSearched(false);
|
|
89
|
+
router.push(`/runs/${runId}`);
|
|
90
|
+
},
|
|
91
|
+
[router]
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Keyboard navigation
|
|
95
|
+
const handleKeyDown = useCallback(
|
|
96
|
+
(e: React.KeyboardEvent) => {
|
|
97
|
+
if (!isOpen) return;
|
|
98
|
+
|
|
99
|
+
switch (e.key) {
|
|
100
|
+
case "ArrowDown":
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
setSelectedIndex((prev) =>
|
|
103
|
+
prev < results.length - 1 ? prev + 1 : 0
|
|
104
|
+
);
|
|
105
|
+
break;
|
|
106
|
+
case "ArrowUp":
|
|
107
|
+
e.preventDefault();
|
|
108
|
+
setSelectedIndex((prev) =>
|
|
109
|
+
prev > 0 ? prev - 1 : results.length - 1
|
|
110
|
+
);
|
|
111
|
+
break;
|
|
112
|
+
case "Enter":
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
if (selectedIndex >= 0 && selectedIndex < results.length) {
|
|
115
|
+
navigateToRun(results[selectedIndex].runId);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
case "Escape":
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
setIsOpen(false);
|
|
121
|
+
inputRef.current?.blur();
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[isOpen, results, selectedIndex, navigateToRun]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Scroll selected item into view
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (selectedIndex >= 0 && listRef.current) {
|
|
131
|
+
const items = listRef.current.querySelectorAll("[data-search-item]");
|
|
132
|
+
items[selectedIndex]?.scrollIntoView({ block: "nearest" });
|
|
133
|
+
}
|
|
134
|
+
}, [selectedIndex]);
|
|
135
|
+
|
|
136
|
+
// Cmd+K / Ctrl+K and "/" global shortcut
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const handler = (e: KeyboardEvent) => {
|
|
139
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
inputRef.current?.focus();
|
|
142
|
+
setIsOpen(true);
|
|
143
|
+
}
|
|
144
|
+
// "/" key to focus search (skip if already in an input)
|
|
145
|
+
if (
|
|
146
|
+
e.key === "/" &&
|
|
147
|
+
!e.metaKey && !e.ctrlKey && !e.altKey &&
|
|
148
|
+
!(e.target instanceof HTMLInputElement) &&
|
|
149
|
+
!(e.target instanceof HTMLTextAreaElement) &&
|
|
150
|
+
!(e.target instanceof HTMLSelectElement) &&
|
|
151
|
+
!(e.target as HTMLElement)?.isContentEditable
|
|
152
|
+
) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
inputRef.current?.focus();
|
|
155
|
+
setIsOpen(true);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
window.addEventListener("keydown", handler);
|
|
159
|
+
return () => window.removeEventListener("keydown", handler);
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
// Close dropdown on outside click
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const handler = (e: MouseEvent) => {
|
|
165
|
+
if (
|
|
166
|
+
containerRef.current &&
|
|
167
|
+
!containerRef.current.contains(e.target as Node)
|
|
168
|
+
) {
|
|
169
|
+
setIsOpen(false);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
document.addEventListener("mousedown", handler);
|
|
173
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
const showDropdown = isOpen && (query.trim().length > 0);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div ref={containerRef} className="relative w-full max-w-xl mx-auto mb-6">
|
|
180
|
+
{/* Search input */}
|
|
181
|
+
<div className="relative">
|
|
182
|
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-foreground-muted pointer-events-none" />
|
|
183
|
+
<input
|
|
184
|
+
ref={inputRef}
|
|
185
|
+
type="text"
|
|
186
|
+
value={query}
|
|
187
|
+
onChange={(e) => {
|
|
188
|
+
setQuery(e.target.value);
|
|
189
|
+
setIsOpen(true);
|
|
190
|
+
}}
|
|
191
|
+
onFocus={() => {
|
|
192
|
+
if (query.trim()) setIsOpen(true);
|
|
193
|
+
}}
|
|
194
|
+
onKeyDown={handleKeyDown}
|
|
195
|
+
placeholder="Search runs by ID, process name, or task title..."
|
|
196
|
+
className={cn(
|
|
197
|
+
"w-full rounded-lg border border-border bg-card/80 backdrop-blur-sm",
|
|
198
|
+
"pl-10 pr-20 py-2.5 text-sm text-foreground",
|
|
199
|
+
"placeholder:text-foreground-muted/60",
|
|
200
|
+
"focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/30",
|
|
201
|
+
"focus:shadow-neon-glow-primary-focus transition-all"
|
|
202
|
+
)}
|
|
203
|
+
data-testid="global-search-input"
|
|
204
|
+
/>
|
|
205
|
+
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5">
|
|
206
|
+
{query && (
|
|
207
|
+
<button
|
|
208
|
+
onClick={() => {
|
|
209
|
+
setQuery("");
|
|
210
|
+
setResults([]);
|
|
211
|
+
setHasSearched(false);
|
|
212
|
+
setIsOpen(false);
|
|
213
|
+
inputRef.current?.focus();
|
|
214
|
+
}}
|
|
215
|
+
className="rounded-md p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-foreground-muted hover:text-foreground-secondary transition-colors"
|
|
216
|
+
aria-label="Clear search"
|
|
217
|
+
>
|
|
218
|
+
<X className="h-3.5 w-3.5" />
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
<kbd className="hidden sm:inline-flex items-center gap-0.5 rounded border border-border bg-background-secondary px-1.5 py-0.5 text-xs font-medium text-foreground-muted">
|
|
222
|
+
<span className="text-xs">{typeof navigator !== "undefined" && /Mac/i.test(navigator.userAgent) ? "\u2318" : "Ctrl"}</span>
|
|
223
|
+
<span>K</span>
|
|
224
|
+
</kbd>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Dropdown results */}
|
|
229
|
+
{showDropdown && (
|
|
230
|
+
<div
|
|
231
|
+
className={cn(
|
|
232
|
+
"absolute z-50 top-full left-0 right-0 mt-1.5",
|
|
233
|
+
"rounded-lg border border-border bg-card/95 backdrop-blur-md shadow-xl",
|
|
234
|
+
"overflow-hidden"
|
|
235
|
+
)}
|
|
236
|
+
data-testid="global-search-dropdown"
|
|
237
|
+
>
|
|
238
|
+
{isLoading && (
|
|
239
|
+
<div className="flex items-center gap-2 px-4 py-3 text-xs text-foreground-muted">
|
|
240
|
+
<div className="h-3 w-3 rounded-full border-2 border-primary/40 border-t-primary animate-spin" />
|
|
241
|
+
Searching...
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{!isLoading && hasSearched && results.length === 0 && (
|
|
246
|
+
<div className="px-4 py-6 text-center">
|
|
247
|
+
<Search className="h-5 w-5 text-foreground-muted/30 mx-auto mb-2" />
|
|
248
|
+
<p className="text-xs text-foreground-muted">
|
|
249
|
+
No runs found for “{debouncedQuery}”
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
|
|
254
|
+
{!isLoading && results.length > 0 && (
|
|
255
|
+
<ul ref={listRef} className="max-h-80 overflow-y-auto py-1" role="listbox">
|
|
256
|
+
{results.map((run, index) => (
|
|
257
|
+
<li
|
|
258
|
+
key={run.runId}
|
|
259
|
+
data-search-item
|
|
260
|
+
role="option"
|
|
261
|
+
aria-selected={index === selectedIndex}
|
|
262
|
+
onClick={() => navigateToRun(run.runId)}
|
|
263
|
+
className={cn(
|
|
264
|
+
"flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors",
|
|
265
|
+
index === selectedIndex
|
|
266
|
+
? "bg-primary/10"
|
|
267
|
+
: "hover:bg-background-secondary/60"
|
|
268
|
+
)}
|
|
269
|
+
>
|
|
270
|
+
{/* Status dot */}
|
|
271
|
+
<span
|
|
272
|
+
className={cn(
|
|
273
|
+
"h-2 w-2 rounded-full shrink-0",
|
|
274
|
+
run.status === "completed"
|
|
275
|
+
? "bg-success"
|
|
276
|
+
: run.status === "failed"
|
|
277
|
+
? "bg-error"
|
|
278
|
+
: run.status === "waiting" || run.status === "pending"
|
|
279
|
+
? "bg-warning"
|
|
280
|
+
: "bg-foreground-muted"
|
|
281
|
+
)}
|
|
282
|
+
/>
|
|
283
|
+
{/* Info */}
|
|
284
|
+
<div className="flex-1 min-w-0">
|
|
285
|
+
<div className="flex items-center gap-2">
|
|
286
|
+
<span className="text-sm font-medium text-foreground truncate">
|
|
287
|
+
{friendlyProcessName(run.processId)}
|
|
288
|
+
</span>
|
|
289
|
+
<StatusBadge
|
|
290
|
+
status={run.status}
|
|
291
|
+
waitingKind={run.waitingKind}
|
|
292
|
+
isStale={run.isStale}
|
|
293
|
+
/>
|
|
294
|
+
</div>
|
|
295
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
296
|
+
{run.projectName && (
|
|
297
|
+
<span className="text-xs text-foreground-muted">
|
|
298
|
+
{run.projectName}
|
|
299
|
+
</span>
|
|
300
|
+
)}
|
|
301
|
+
<span className="font-mono text-xs text-info">
|
|
302
|
+
{formatShortId(run.runId, 8)}
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
{/* Navigate arrow */}
|
|
307
|
+
<ArrowRight
|
|
308
|
+
className={cn(
|
|
309
|
+
"h-3.5 w-3.5 shrink-0 transition-colors",
|
|
310
|
+
index === selectedIndex
|
|
311
|
+
? "text-primary"
|
|
312
|
+
: "text-foreground-muted/40"
|
|
313
|
+
)}
|
|
314
|
+
/>
|
|
315
|
+
</li>
|
|
316
|
+
))}
|
|
317
|
+
</ul>
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import {
|
|
3
|
+
Layers,
|
|
4
|
+
Activity,
|
|
5
|
+
CheckCircle2,
|
|
6
|
+
AlertCircle,
|
|
7
|
+
Pause,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { cn } from "@/lib/cn";
|
|
10
|
+
import { useAnimatedNumber } from "@/hooks/use-animated-number";
|
|
11
|
+
import type { DashboardMetrics, DashboardStatusFilter } from "@/hooks/use-run-dashboard";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// MetricTile (internal)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
interface MetricTileProps {
|
|
18
|
+
label: string;
|
|
19
|
+
value: number;
|
|
20
|
+
icon: React.ReactNode;
|
|
21
|
+
color: "primary" | "success" | "warning" | "error" | "muted";
|
|
22
|
+
pulse?: boolean;
|
|
23
|
+
testId?: string;
|
|
24
|
+
active?: boolean;
|
|
25
|
+
onClick?: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const colorMap: Record<MetricTileProps["color"], { text: string; bg: string; glow: string; borderL: string; ring: string }> = {
|
|
29
|
+
primary: { text: "text-primary", bg: "bg-primary/10", glow: "shadow-neon-glow-primary-xs", borderL: "", ring: "ring-primary/50" },
|
|
30
|
+
success: { text: "text-success", bg: "bg-success/10", glow: "shadow-neon-glow-success-sm", borderL: "border-l-success/60", ring: "ring-success/50" },
|
|
31
|
+
warning: { text: "text-warning", bg: "bg-warning/10", glow: "shadow-neon-glow-warning-sm", borderL: "border-l-warning/60", ring: "ring-warning/50" },
|
|
32
|
+
error: { text: "text-error", bg: "bg-error/10", glow: "shadow-neon-glow-error-sm", borderL: "border-l-error/60", ring: "ring-error/50" },
|
|
33
|
+
muted: { text: "text-zinc-500", bg: "bg-zinc-500/10", glow: "", borderL: "border-l-zinc-500/60", ring: "ring-zinc-500/50" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function MetricTile({ label, value, icon, color, pulse, testId, active, onClick }: MetricTileProps) {
|
|
37
|
+
const c = colorMap[color];
|
|
38
|
+
const isClickable = !!onClick;
|
|
39
|
+
// Animate the number so jumps like 48->91 feel smooth instead of jarring
|
|
40
|
+
const displayValue = useAnimatedNumber(value);
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
data-testid={testId}
|
|
44
|
+
role={isClickable ? "button" : undefined}
|
|
45
|
+
aria-pressed={isClickable ? !!active : undefined}
|
|
46
|
+
tabIndex={isClickable ? 0 : undefined}
|
|
47
|
+
onClick={onClick}
|
|
48
|
+
onKeyDown={isClickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick?.(); } } : undefined}
|
|
49
|
+
className={cn(
|
|
50
|
+
"rounded-xl border border-border bg-card/90 p-3 flex items-center gap-3 transition-all shadow-sm",
|
|
51
|
+
value > 0 && color !== "primary" && "border-l-2",
|
|
52
|
+
value > 0 && color !== "primary" && c.borderL,
|
|
53
|
+
c.glow,
|
|
54
|
+
isClickable && "cursor-pointer hover:bg-background-secondary/65 hover:shadow-md",
|
|
55
|
+
active && "ring-2",
|
|
56
|
+
active && c.ring,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<div className={cn("rounded-md p-2", c.bg)}>
|
|
60
|
+
<span className={cn(c.text, pulse && "animate-pulse-dot")}>{icon}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<p className={cn("text-lg font-bold tabular-nums leading-none mb-0.5", c.text)}>
|
|
64
|
+
{displayValue}
|
|
65
|
+
</p>
|
|
66
|
+
<p className="text-[11px] leading-tight text-foreground-muted uppercase tracking-[0.12em] font-medium">
|
|
67
|
+
{label}
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// KpiGrid (public)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export interface KpiGridProps {
|
|
79
|
+
metrics: DashboardMetrics;
|
|
80
|
+
statusFilter: DashboardStatusFilter;
|
|
81
|
+
hasStaleRuns: boolean;
|
|
82
|
+
onToggleFilter: (filter: DashboardStatusFilter) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function KpiGrid({ metrics, statusFilter, hasStaleRuns, onToggleFilter }: KpiGridProps) {
|
|
86
|
+
const kpiCols = hasStaleRuns ? "grid-cols-2 sm:grid-cols-5" : "grid-cols-2 sm:grid-cols-4";
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div data-testid="kpi-grid" aria-live="polite" aria-label="Key metrics" className={cn("grid gap-3 mb-6", kpiCols)}>
|
|
90
|
+
<MetricTile
|
|
91
|
+
label="Total Runs"
|
|
92
|
+
value={metrics.totalRuns}
|
|
93
|
+
icon={<Layers className="h-4 w-4" />}
|
|
94
|
+
color="primary"
|
|
95
|
+
testId="metric-tile-total-runs"
|
|
96
|
+
onClick={() => onToggleFilter("all")}
|
|
97
|
+
active={statusFilter === "all"}
|
|
98
|
+
/>
|
|
99
|
+
<MetricTile
|
|
100
|
+
label="In Progress"
|
|
101
|
+
value={metrics.activeRuns}
|
|
102
|
+
icon={<Activity className="h-4 w-4" />}
|
|
103
|
+
color="warning"
|
|
104
|
+
pulse={metrics.activeRuns > 0}
|
|
105
|
+
testId="metric-tile-active"
|
|
106
|
+
onClick={() => onToggleFilter("waiting")}
|
|
107
|
+
active={statusFilter === "waiting"}
|
|
108
|
+
/>
|
|
109
|
+
{hasStaleRuns && (
|
|
110
|
+
<MetricTile
|
|
111
|
+
label="Stale"
|
|
112
|
+
value={metrics.staleRuns}
|
|
113
|
+
icon={<Pause className="h-4 w-4" />}
|
|
114
|
+
color="muted"
|
|
115
|
+
testId="metric-tile-stale"
|
|
116
|
+
onClick={() => onToggleFilter("stale")}
|
|
117
|
+
active={statusFilter === "stale"}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
<MetricTile
|
|
121
|
+
label="Completed"
|
|
122
|
+
value={metrics.completedRuns}
|
|
123
|
+
icon={<CheckCircle2 className="h-4 w-4" />}
|
|
124
|
+
color="success"
|
|
125
|
+
testId="metric-tile-completed"
|
|
126
|
+
onClick={() => onToggleFilter("completed")}
|
|
127
|
+
active={statusFilter === "completed"}
|
|
128
|
+
/>
|
|
129
|
+
<MetricTile
|
|
130
|
+
label="Failed"
|
|
131
|
+
value={metrics.failedRuns}
|
|
132
|
+
icon={<AlertCircle className="h-4 w-4" />}
|
|
133
|
+
color="error"
|
|
134
|
+
testId="metric-tile-failed"
|
|
135
|
+
onClick={() => onToggleFilter("failed")}
|
|
136
|
+
active={statusFilter === "failed"}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { cn } from '@/lib/cn';
|
|
4
|
+
|
|
5
|
+
interface PaginationControlsProps {
|
|
6
|
+
currentPage: number;
|
|
7
|
+
totalItems: number;
|
|
8
|
+
itemsPerPage: number;
|
|
9
|
+
onPageChange: (page: number) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PaginationControls({
|
|
14
|
+
currentPage,
|
|
15
|
+
totalItems,
|
|
16
|
+
itemsPerPage,
|
|
17
|
+
onPageChange,
|
|
18
|
+
className
|
|
19
|
+
}: PaginationControlsProps) {
|
|
20
|
+
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
|
21
|
+
const startItem = currentPage * itemsPerPage + 1;
|
|
22
|
+
const endItem = Math.min((currentPage + 1) * itemsPerPage, totalItems);
|
|
23
|
+
|
|
24
|
+
const canGoPrev = currentPage > 0;
|
|
25
|
+
const canGoNext = currentPage < totalPages - 1;
|
|
26
|
+
|
|
27
|
+
if (totalItems === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Build visible page numbers: show first, last, current, and neighbors
|
|
32
|
+
const pageNumbers: (number | 'ellipsis')[] = [];
|
|
33
|
+
if (totalPages <= 5) {
|
|
34
|
+
for (let i = 0; i < totalPages; i++) pageNumbers.push(i);
|
|
35
|
+
} else {
|
|
36
|
+
pageNumbers.push(0);
|
|
37
|
+
if (currentPage > 2) pageNumbers.push('ellipsis');
|
|
38
|
+
for (let i = Math.max(1, currentPage - 1); i <= Math.min(totalPages - 2, currentPage + 1); i++) {
|
|
39
|
+
pageNumbers.push(i);
|
|
40
|
+
}
|
|
41
|
+
if (currentPage < totalPages - 3) pageNumbers.push('ellipsis');
|
|
42
|
+
pageNumbers.push(totalPages - 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('flex items-center justify-between border-t border-border pt-3', className)}>
|
|
47
|
+
<span className="text-xs text-foreground-muted tabular-nums">
|
|
48
|
+
{startItem}–{endItem} of {totalItems}
|
|
49
|
+
</span>
|
|
50
|
+
<div className="flex items-center gap-0.5">
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
53
|
+
disabled={!canGoPrev}
|
|
54
|
+
className={cn(
|
|
55
|
+
'inline-flex h-11 w-11 items-center justify-center rounded-md text-xs transition-all',
|
|
56
|
+
canGoPrev
|
|
57
|
+
? 'text-foreground hover:bg-primary-muted hover:text-primary hover:shadow-neon-glow-primary-xs cursor-pointer'
|
|
58
|
+
: 'text-foreground-muted cursor-not-allowed opacity-40'
|
|
59
|
+
)}
|
|
60
|
+
aria-label="Previous page"
|
|
61
|
+
>
|
|
62
|
+
<ChevronLeft className="h-4 w-4" />
|
|
63
|
+
</button>
|
|
64
|
+
{pageNumbers.map((p, idx) =>
|
|
65
|
+
p === 'ellipsis' ? (
|
|
66
|
+
<span key={`ellipsis-${idx}`} className="inline-flex h-11 w-5 items-center justify-center text-xs text-foreground-muted">
|
|
67
|
+
...
|
|
68
|
+
</span>
|
|
69
|
+
) : (
|
|
70
|
+
<button
|
|
71
|
+
key={p}
|
|
72
|
+
onClick={() => onPageChange(p)}
|
|
73
|
+
className={cn(
|
|
74
|
+
'inline-flex h-11 min-w-[44px] items-center justify-center rounded-md px-1.5 text-xs font-medium tabular-nums transition-all',
|
|
75
|
+
p === currentPage
|
|
76
|
+
? 'text-primary bg-primary/10'
|
|
77
|
+
: 'text-foreground-muted hover:bg-background-secondary hover:text-foreground-secondary cursor-pointer'
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{p + 1}
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
)}
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
86
|
+
disabled={!canGoNext}
|
|
87
|
+
className={cn(
|
|
88
|
+
'inline-flex h-11 w-11 items-center justify-center rounded-md text-xs transition-all',
|
|
89
|
+
canGoNext
|
|
90
|
+
? 'text-foreground hover:bg-primary-muted hover:text-primary hover:shadow-neon-glow-primary-xs cursor-pointer'
|
|
91
|
+
: 'text-foreground-muted cursor-not-allowed opacity-40'
|
|
92
|
+
)}
|
|
93
|
+
aria-label="Next page"
|
|
94
|
+
>
|
|
95
|
+
<ChevronRight className="h-4 w-4" />
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Accordion,
|
|
5
|
+
AccordionItem,
|
|
6
|
+
AccordionTrigger,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
} from "@/components/ui/accordion";
|
|
9
|
+
import { ProjectSectionHeader } from "./project-section-header";
|
|
10
|
+
import { ProjectSection } from "./project-section";
|
|
11
|
+
import type { ProjectSummary } from "@/types";
|
|
12
|
+
|
|
13
|
+
interface ProjectAccordionProps {
|
|
14
|
+
projects: ProjectSummary[];
|
|
15
|
+
statusFilter?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ProjectAccordion({ projects, statusFilter, className }: ProjectAccordionProps) {
|
|
20
|
+
// Default-open projects that have active runs
|
|
21
|
+
const defaultOpen = projects
|
|
22
|
+
.filter((p) => p.activeRuns > 0)
|
|
23
|
+
.map((p) => p.projectName);
|
|
24
|
+
|
|
25
|
+
// Track which projects are expanded
|
|
26
|
+
const [expandedProjects, setExpandedProjects] = useState<string[]>(defaultOpen);
|
|
27
|
+
|
|
28
|
+
if (projects.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="text-sm text-foreground-muted text-center py-8">
|
|
31
|
+
No projects found
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Accordion
|
|
38
|
+
type="multiple"
|
|
39
|
+
defaultValue={defaultOpen}
|
|
40
|
+
value={expandedProjects}
|
|
41
|
+
onValueChange={(value) => setExpandedProjects(Array.isArray(value) ? value : [value])}
|
|
42
|
+
className={className}
|
|
43
|
+
>
|
|
44
|
+
{projects.map((project) => {
|
|
45
|
+
const isExpanded = expandedProjects.includes(project.projectName);
|
|
46
|
+
return (
|
|
47
|
+
<AccordionItem key={project.projectName} value={project.projectName}>
|
|
48
|
+
<AccordionTrigger className="px-2 hover:bg-primary-muted/30 rounded-md transition-colors">
|
|
49
|
+
<ProjectSectionHeader
|
|
50
|
+
projectName={project.projectName}
|
|
51
|
+
activeRuns={project.activeRuns}
|
|
52
|
+
completedRuns={project.completedRuns}
|
|
53
|
+
failedRuns={project.failedRuns}
|
|
54
|
+
totalRuns={project.totalRuns}
|
|
55
|
+
latestUpdate={project.latestUpdate}
|
|
56
|
+
/>
|
|
57
|
+
</AccordionTrigger>
|
|
58
|
+
<AccordionContent className="px-2">
|
|
59
|
+
<ProjectSection
|
|
60
|
+
projectName={project.projectName}
|
|
61
|
+
runs={[]}
|
|
62
|
+
defaultExpanded
|
|
63
|
+
statusFilter={statusFilter}
|
|
64
|
+
enabled={isExpanded}
|
|
65
|
+
/>
|
|
66
|
+
</AccordionContent>
|
|
67
|
+
</AccordionItem>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</Accordion>
|
|
71
|
+
);
|
|
72
|
+
}
|