@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,536 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useCallback } from "react";
|
|
3
|
+
import { cn } from "@/lib/cn";
|
|
4
|
+
import { Card } from "@/components/ui/card";
|
|
5
|
+
import { RunCard } from "./run-card";
|
|
6
|
+
import { VirtualizedRunList } from "./virtualized-run-list";
|
|
7
|
+
import { PaginationControls } from "./pagination-controls";
|
|
8
|
+
import { useProjectRuns } from "@/hooks/use-project-runs";
|
|
9
|
+
import { usePersistedState } from "@/hooks/use-persisted-state";
|
|
10
|
+
import { resilientFetch } from "@/lib/fetcher";
|
|
11
|
+
import { formatRelativeTime } from "@/lib/utils";
|
|
12
|
+
import type { ProjectSummary, RunStatus } from "@/types";
|
|
13
|
+
import {
|
|
14
|
+
Activity,
|
|
15
|
+
CheckCircle2,
|
|
16
|
+
AlertCircle,
|
|
17
|
+
Layers,
|
|
18
|
+
ChevronDown,
|
|
19
|
+
ChevronUp,
|
|
20
|
+
Clock,
|
|
21
|
+
Pause,
|
|
22
|
+
History,
|
|
23
|
+
Hand,
|
|
24
|
+
EyeOff,
|
|
25
|
+
Loader2,
|
|
26
|
+
} from "lucide-react";
|
|
27
|
+
|
|
28
|
+
interface ProjectHealthCardProps {
|
|
29
|
+
project: ProjectSummary;
|
|
30
|
+
statusFilter: RunStatus | "all";
|
|
31
|
+
sortMode?: "status" | "activity";
|
|
32
|
+
onHide?: (projectName: string) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type HealthStatus = "healthy" | "active" | "stale" | "failing";
|
|
36
|
+
|
|
37
|
+
function getHealthStatus(project: ProjectSummary): HealthStatus {
|
|
38
|
+
if (project.failedRuns > 0) return "failing";
|
|
39
|
+
if (project.activeRuns > 0) return "active";
|
|
40
|
+
if (project.staleRuns > 0) return "stale";
|
|
41
|
+
return "healthy";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const healthConfig: Record<
|
|
45
|
+
HealthStatus,
|
|
46
|
+
{ dotClass: string; borderClass: string; icon: typeof CheckCircle2; label: string; barColor: string }
|
|
47
|
+
> = {
|
|
48
|
+
healthy: {
|
|
49
|
+
dotClass: "bg-success shadow-[0_0_6px_var(--success)]",
|
|
50
|
+
borderClass: "border-success/20 hover:border-success/40",
|
|
51
|
+
icon: CheckCircle2,
|
|
52
|
+
label: "Healthy",
|
|
53
|
+
barColor: "bg-success",
|
|
54
|
+
},
|
|
55
|
+
active: {
|
|
56
|
+
dotClass: "bg-warning shadow-[0_0_6px_var(--warning)] animate-pulse-dot",
|
|
57
|
+
borderClass: "border-warning/20 hover:border-warning/40",
|
|
58
|
+
icon: Activity,
|
|
59
|
+
label: "Active",
|
|
60
|
+
barColor: "bg-warning",
|
|
61
|
+
},
|
|
62
|
+
stale: {
|
|
63
|
+
dotClass: "bg-zinc-500",
|
|
64
|
+
borderClass: "border-zinc-500/20 hover:border-zinc-500/40",
|
|
65
|
+
icon: Pause,
|
|
66
|
+
label: "Stale",
|
|
67
|
+
barColor: "bg-zinc-500",
|
|
68
|
+
},
|
|
69
|
+
failing: {
|
|
70
|
+
dotClass: "bg-error shadow-[0_0_6px_var(--error)]",
|
|
71
|
+
borderClass: "border-error/20 hover:border-error/40",
|
|
72
|
+
icon: AlertCircle,
|
|
73
|
+
label: "Failing",
|
|
74
|
+
barColor: "bg-error",
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const PAGE_SIZE = 5;
|
|
79
|
+
|
|
80
|
+
export function ProjectHealthCard({ project, statusFilter, sortMode = "status", onHide }: ProjectHealthCardProps) {
|
|
81
|
+
const [hiding, setHiding] = useState(false);
|
|
82
|
+
|
|
83
|
+
const handleHide = useCallback(async (e: React.MouseEvent) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
setHiding(true);
|
|
86
|
+
|
|
87
|
+
// First fetch current config to get existing hiddenProjects
|
|
88
|
+
const configResult = await resilientFetch<{ hiddenProjects?: string[]; sources: { path: string; depth: number; label?: string }[]; pollInterval: number; theme: string; retentionDays: number }>("/api/config");
|
|
89
|
+
if (!configResult.ok) {
|
|
90
|
+
setHiding(false);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const currentHidden = configResult.data.hiddenProjects ?? [];
|
|
95
|
+
if (currentHidden.includes(project.projectName)) {
|
|
96
|
+
// Already hidden
|
|
97
|
+
setHiding(false);
|
|
98
|
+
onHide?.(project.projectName);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newHidden = [...currentHidden, project.projectName];
|
|
103
|
+
const saveResult = await resilientFetch("/api/config", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json" },
|
|
106
|
+
body: JSON.stringify({
|
|
107
|
+
sources: configResult.data.sources,
|
|
108
|
+
pollInterval: configResult.data.pollInterval,
|
|
109
|
+
theme: configResult.data.theme,
|
|
110
|
+
retentionDays: configResult.data.retentionDays,
|
|
111
|
+
hiddenProjects: newHidden,
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
setHiding(false);
|
|
116
|
+
if (saveResult.ok) {
|
|
117
|
+
onHide?.(project.projectName);
|
|
118
|
+
}
|
|
119
|
+
}, [project.projectName, onHide]);
|
|
120
|
+
|
|
121
|
+
const [expanded, setExpanded] = usePersistedState(
|
|
122
|
+
`observer:project-expanded:${project.projectName}`,
|
|
123
|
+
false
|
|
124
|
+
);
|
|
125
|
+
const [page, setPage] = useState(0);
|
|
126
|
+
const [showCompleted, setShowCompleted] = useState(false);
|
|
127
|
+
const [showFailed, setShowFailed] = useState(false);
|
|
128
|
+
const [localFilter, setLocalFilter] = useState<RunStatus | "all">("all");
|
|
129
|
+
|
|
130
|
+
const health = getHealthStatus(project);
|
|
131
|
+
const config = healthConfig[health];
|
|
132
|
+
const StatusIcon = config.icon;
|
|
133
|
+
|
|
134
|
+
const taskProgress = project.totalTasks > 0
|
|
135
|
+
? Math.round((project.completedTasksAggregate / project.totalTasks) * 100)
|
|
136
|
+
: 0;
|
|
137
|
+
|
|
138
|
+
// Effective filter: local filter takes precedence when set, otherwise use parent filter
|
|
139
|
+
const effectiveFilter = localFilter !== "all" ? localFilter : statusFilter;
|
|
140
|
+
|
|
141
|
+
const { runs, totalCount, loading } = useProjectRuns(
|
|
142
|
+
project.projectName,
|
|
143
|
+
{
|
|
144
|
+
limit: PAGE_SIZE,
|
|
145
|
+
offset: page * PAGE_SIZE,
|
|
146
|
+
status: effectiveFilter === "all" ? "" : effectiveFilter,
|
|
147
|
+
sort: sortMode,
|
|
148
|
+
enabled: expanded,
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Toggle local filter from mini KPI pills: clicking active filter clears it
|
|
153
|
+
const toggleLocalFilter = (filter: RunStatus | "all") => {
|
|
154
|
+
setLocalFilter((prev) => (prev === filter ? "all" : filter));
|
|
155
|
+
setPage(0); // Reset pagination when filter changes
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Card
|
|
160
|
+
data-testid={`project-card-${project.projectName}`}
|
|
161
|
+
className={cn(
|
|
162
|
+
"transition-all duration-200 overflow-hidden card-hover-lift",
|
|
163
|
+
config.borderClass,
|
|
164
|
+
expanded && "ring-1 ring-primary/20"
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
{/* Card header */}
|
|
168
|
+
<button
|
|
169
|
+
onClick={() => setExpanded((v) => !v)}
|
|
170
|
+
className="w-full text-left p-4 hover:bg-background-secondary/30 transition-colors"
|
|
171
|
+
>
|
|
172
|
+
{/* Row 1: Project title — full width, visually dominant */}
|
|
173
|
+
<div className="flex items-center justify-between mb-2">
|
|
174
|
+
<div className="flex items-center gap-2 truncate flex-1">
|
|
175
|
+
<h3 className="text-lg font-semibold text-foreground truncate">
|
|
176
|
+
{project.projectName}
|
|
177
|
+
</h3>
|
|
178
|
+
{project.pendingBreakpoints > 0 && (
|
|
179
|
+
<span className={cn(
|
|
180
|
+
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 shrink-0",
|
|
181
|
+
"bg-warning/15 border border-warning/30",
|
|
182
|
+
"text-xs leading-tight font-bold text-warning",
|
|
183
|
+
"animate-pulse-dot"
|
|
184
|
+
)}>
|
|
185
|
+
<Hand className="h-2.5 w-2.5" />
|
|
186
|
+
{project.pendingBreakpoints} Pending
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center shrink-0 ml-2 gap-1">
|
|
191
|
+
<span
|
|
192
|
+
role="button"
|
|
193
|
+
tabIndex={0}
|
|
194
|
+
onClick={handleHide}
|
|
195
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleHide(e as unknown as React.MouseEvent); } }}
|
|
196
|
+
className={cn(
|
|
197
|
+
"rounded-md p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center transition-colors",
|
|
198
|
+
hiding
|
|
199
|
+
? "text-foreground-muted cursor-wait"
|
|
200
|
+
: "text-foreground-muted/40 hover:text-foreground-muted hover:bg-background-secondary"
|
|
201
|
+
)}
|
|
202
|
+
title="Hide project from dashboard"
|
|
203
|
+
>
|
|
204
|
+
{hiding ? (
|
|
205
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
206
|
+
) : (
|
|
207
|
+
<EyeOff className="h-3.5 w-3.5" />
|
|
208
|
+
)}
|
|
209
|
+
</span>
|
|
210
|
+
{expanded ? (
|
|
211
|
+
<ChevronUp className="h-4 w-4 text-foreground-muted" />
|
|
212
|
+
) : (
|
|
213
|
+
<ChevronDown className="h-4 w-4 text-foreground-muted" />
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Row 2: Health status + run count badges */}
|
|
219
|
+
<div className="flex items-center justify-between mb-3">
|
|
220
|
+
<div className="flex items-center gap-3 text-xs text-foreground-muted">
|
|
221
|
+
<span className="inline-flex items-center gap-1.5">
|
|
222
|
+
<span className={cn("h-2.5 w-2.5 rounded-full shrink-0", config.dotClass)} />
|
|
223
|
+
<StatusIcon className="h-3 w-3" />
|
|
224
|
+
{config.label}
|
|
225
|
+
</span>
|
|
226
|
+
<span className="inline-flex items-center gap-1">
|
|
227
|
+
<Layers className="h-3 w-3" />
|
|
228
|
+
{project.totalRuns}
|
|
229
|
+
</span>
|
|
230
|
+
<span className="inline-flex items-center gap-1">
|
|
231
|
+
<Clock className="h-3 w-3" />
|
|
232
|
+
{formatRelativeTime(project.latestUpdate)}
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
235
|
+
{/* Compact status badges with icons */}
|
|
236
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
237
|
+
{project.activeRuns > 0 && (
|
|
238
|
+
<span className="inline-flex items-center gap-0.5 rounded-full bg-warning/10 border border-warning/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-warning tabular-nums" title={`${project.activeRuns} active`}>
|
|
239
|
+
<Activity className="h-3 w-3" />
|
|
240
|
+
{project.activeRuns}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
{project.staleRuns > 0 && (
|
|
244
|
+
<span className="inline-flex items-center gap-0.5 rounded-full bg-zinc-500/10 border border-zinc-500/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-zinc-500 tabular-nums" title={`${project.staleRuns} stale`}>
|
|
245
|
+
<Pause className="h-3 w-3" />
|
|
246
|
+
{project.staleRuns}
|
|
247
|
+
</span>
|
|
248
|
+
)}
|
|
249
|
+
{project.completedRuns > 0 && (
|
|
250
|
+
<span className="inline-flex items-center gap-0.5 rounded-full bg-success/10 border border-success/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-success tabular-nums" title={`${project.completedRuns} completed`}>
|
|
251
|
+
<CheckCircle2 className="h-3 w-3" />
|
|
252
|
+
{project.completedRuns}
|
|
253
|
+
</span>
|
|
254
|
+
)}
|
|
255
|
+
{project.failedRuns > 0 && (
|
|
256
|
+
<span className="inline-flex items-center gap-0.5 rounded-full bg-error/10 border border-error/20 px-1.5 py-0.5 text-xs leading-tight font-medium text-error tabular-nums" title={`${project.failedRuns} failed`}>
|
|
257
|
+
<AlertCircle className="h-3 w-3" />
|
|
258
|
+
{project.failedRuns}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{/* Row 3: Mini progress bar — task completion */}
|
|
265
|
+
{project.totalTasks > 0 && (
|
|
266
|
+
<div>
|
|
267
|
+
<div className="flex items-center justify-between mb-1">
|
|
268
|
+
<span className="text-xs leading-tight text-foreground-muted">
|
|
269
|
+
{project.completedTasksAggregate}/{project.totalTasks} tasks
|
|
270
|
+
</span>
|
|
271
|
+
<span className="text-xs leading-tight text-foreground-muted tabular-nums">
|
|
272
|
+
{taskProgress}%
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div className="h-1.5 w-full rounded-full bg-background-secondary overflow-hidden">
|
|
276
|
+
<div
|
|
277
|
+
className={cn("h-full rounded-full transition-all duration-500", config.barColor)}
|
|
278
|
+
style={{ width: `${taskProgress}%` }}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)}
|
|
283
|
+
</button>
|
|
284
|
+
|
|
285
|
+
{/* Expanded: runs list — split into active and completed */}
|
|
286
|
+
{expanded && (() => {
|
|
287
|
+
const activeRuns = runs.filter((r) => r.status === "waiting" || r.status === "pending" || r.isStale);
|
|
288
|
+
const successRuns = runs.filter((r) => r.status === "completed" && !r.isStale);
|
|
289
|
+
const failedRuns = runs.filter((r) => r.status === "failed" && !r.isStale);
|
|
290
|
+
const hasActiveRuns = activeRuns.length > 0;
|
|
291
|
+
const hasSuccessRuns = successRuns.length > 0;
|
|
292
|
+
const hasFailedRuns = failedRuns.length > 0;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className="border-t border-border px-4 pb-4 pt-3">
|
|
296
|
+
{loading && runs.length === 0 ? (
|
|
297
|
+
<div className="flex flex-col gap-2">
|
|
298
|
+
{[1, 2].map((i) => (
|
|
299
|
+
<div
|
|
300
|
+
key={i}
|
|
301
|
+
className="rounded-lg border border-border bg-background p-3 animate-pulse"
|
|
302
|
+
>
|
|
303
|
+
<div className="flex items-center gap-2 mb-2">
|
|
304
|
+
<div className="h-2 w-2 rounded-full bg-foreground-muted/20" />
|
|
305
|
+
<div className="h-3 w-32 rounded bg-foreground-muted/10" />
|
|
306
|
+
</div>
|
|
307
|
+
<div className="h-2 w-full rounded bg-foreground-muted/10" />
|
|
308
|
+
</div>
|
|
309
|
+
))}
|
|
310
|
+
</div>
|
|
311
|
+
) : runs.length === 0 ? (
|
|
312
|
+
<p className="text-xs text-foreground-muted text-center py-4">No matching runs</p>
|
|
313
|
+
) : sortMode === "activity" ? (
|
|
314
|
+
/* ── Activity mode: flat chronological list ── */
|
|
315
|
+
<div className="flex flex-col gap-3">
|
|
316
|
+
{/* Mini KPI Row — clickable to filter runs within this project */}
|
|
317
|
+
<div className={cn("grid gap-2 mb-3", project.staleRuns > 0 ? "grid-cols-4" : "grid-cols-3")}>
|
|
318
|
+
<MiniKpiPill
|
|
319
|
+
icon={<Activity className="h-3.5 w-3.5" />}
|
|
320
|
+
count={project.activeRuns}
|
|
321
|
+
label="Active"
|
|
322
|
+
colorClass="text-warning"
|
|
323
|
+
bgClass="bg-warning/10"
|
|
324
|
+
pulse={project.activeRuns > 0}
|
|
325
|
+
active={localFilter === "waiting"}
|
|
326
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
|
|
327
|
+
/>
|
|
328
|
+
{project.staleRuns > 0 && (
|
|
329
|
+
<MiniKpiPill
|
|
330
|
+
icon={<Pause className="h-3.5 w-3.5" />}
|
|
331
|
+
count={project.staleRuns}
|
|
332
|
+
label="Stale"
|
|
333
|
+
colorClass="text-zinc-500"
|
|
334
|
+
bgClass="bg-zinc-500/10"
|
|
335
|
+
active={localFilter === "waiting"}
|
|
336
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
|
|
337
|
+
/>
|
|
338
|
+
)}
|
|
339
|
+
<MiniKpiPill
|
|
340
|
+
icon={<CheckCircle2 className="h-3.5 w-3.5" />}
|
|
341
|
+
count={project.completedRuns}
|
|
342
|
+
label="Completed"
|
|
343
|
+
colorClass="text-success"
|
|
344
|
+
bgClass="bg-success/10"
|
|
345
|
+
active={localFilter === "completed"}
|
|
346
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("completed"); }}
|
|
347
|
+
/>
|
|
348
|
+
<MiniKpiPill
|
|
349
|
+
icon={<AlertCircle className="h-3.5 w-3.5" />}
|
|
350
|
+
count={project.failedRuns}
|
|
351
|
+
label="Failed"
|
|
352
|
+
colorClass="text-error"
|
|
353
|
+
bgClass="bg-error/10"
|
|
354
|
+
active={localFilter === "failed"}
|
|
355
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("failed"); }}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
{/* Flat chronological run list — all runs in one timeline */}
|
|
360
|
+
<div className="flex items-center gap-2 mb-1">
|
|
361
|
+
<Clock className="h-3.5 w-3.5 text-primary" />
|
|
362
|
+
<span className="text-xs font-semibold text-foreground">Timeline</span>
|
|
363
|
+
<span className="rounded-full bg-primary/10 border border-primary/20 px-2 py-px text-xs font-semibold text-primary tabular-nums">
|
|
364
|
+
{runs.length}
|
|
365
|
+
</span>
|
|
366
|
+
</div>
|
|
367
|
+
<VirtualizedRunList
|
|
368
|
+
runs={runs}
|
|
369
|
+
maxHeight={500}
|
|
370
|
+
renderItem={(run) => (
|
|
371
|
+
<div className="relative">
|
|
372
|
+
<RunCard run={run} />
|
|
373
|
+
{/* Relative time overlay label */}
|
|
374
|
+
<span className="absolute top-2 right-2 inline-flex items-center gap-1 rounded-full bg-background/80 backdrop-blur-sm border border-border px-2 py-0.5 text-xs text-foreground-muted tabular-nums pointer-events-none z-10">
|
|
375
|
+
<Clock className="h-2.5 w-2.5" />
|
|
376
|
+
{formatRelativeTime(run.updatedAt)}
|
|
377
|
+
</span>
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
) : (
|
|
383
|
+
/* ── Status mode: grouped sections (original behavior) ── */
|
|
384
|
+
<div className="flex flex-col gap-3">
|
|
385
|
+
{/* Mini KPI Row — clickable to filter runs within this project */}
|
|
386
|
+
<div className={cn("grid gap-2 mb-3", project.staleRuns > 0 ? "grid-cols-4" : "grid-cols-3")}>
|
|
387
|
+
<MiniKpiPill
|
|
388
|
+
icon={<Activity className="h-3.5 w-3.5" />}
|
|
389
|
+
count={project.activeRuns}
|
|
390
|
+
label="Active"
|
|
391
|
+
colorClass="text-warning"
|
|
392
|
+
bgClass="bg-warning/10"
|
|
393
|
+
pulse={project.activeRuns > 0}
|
|
394
|
+
active={localFilter === "waiting"}
|
|
395
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
|
|
396
|
+
/>
|
|
397
|
+
{project.staleRuns > 0 && (
|
|
398
|
+
<MiniKpiPill
|
|
399
|
+
icon={<Pause className="h-3.5 w-3.5" />}
|
|
400
|
+
count={project.staleRuns}
|
|
401
|
+
label="Stale"
|
|
402
|
+
colorClass="text-zinc-500"
|
|
403
|
+
bgClass="bg-zinc-500/10"
|
|
404
|
+
active={localFilter === "waiting"}
|
|
405
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("waiting"); }}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
408
|
+
<MiniKpiPill
|
|
409
|
+
icon={<CheckCircle2 className="h-3.5 w-3.5" />}
|
|
410
|
+
count={project.completedRuns}
|
|
411
|
+
label="Completed"
|
|
412
|
+
colorClass="text-success"
|
|
413
|
+
bgClass="bg-success/10"
|
|
414
|
+
active={localFilter === "completed"}
|
|
415
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("completed"); }}
|
|
416
|
+
/>
|
|
417
|
+
<MiniKpiPill
|
|
418
|
+
icon={<AlertCircle className="h-3.5 w-3.5" />}
|
|
419
|
+
count={project.failedRuns}
|
|
420
|
+
label="Failed"
|
|
421
|
+
colorClass="text-error"
|
|
422
|
+
bgClass="bg-error/10"
|
|
423
|
+
active={localFilter === "failed"}
|
|
424
|
+
onClick={(e) => { e.stopPropagation(); toggleLocalFilter("failed"); }}
|
|
425
|
+
/>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
{/* Active runs — always visible with section header */}
|
|
429
|
+
{hasActiveRuns && (
|
|
430
|
+
<div className="mb-2">
|
|
431
|
+
<div className="flex items-center gap-2 mb-2">
|
|
432
|
+
<Activity className="h-3.5 w-3.5 text-warning animate-pulse-dot" />
|
|
433
|
+
<span className="text-xs font-semibold text-foreground">In Progress</span>
|
|
434
|
+
<span className="rounded-full bg-warning/10 border border-warning/20 px-2 py-px text-xs font-semibold text-warning tabular-nums">
|
|
435
|
+
{activeRuns.length}
|
|
436
|
+
</span>
|
|
437
|
+
</div>
|
|
438
|
+
<VirtualizedRunList runs={activeRuns} maxHeight={500} />
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{/* Failed runs — collapsible section */}
|
|
443
|
+
{hasFailedRuns && (
|
|
444
|
+
<div>
|
|
445
|
+
<button
|
|
446
|
+
onClick={(e) => { e.stopPropagation(); setShowFailed((v) => !v); }}
|
|
447
|
+
className="flex items-center gap-2 py-1.5 text-xs group w-fit"
|
|
448
|
+
>
|
|
449
|
+
<AlertCircle className="h-3.5 w-3.5 text-error/70" />
|
|
450
|
+
<span className="font-semibold text-error/80 group-hover:text-error transition-colors">Failed Runs</span>
|
|
451
|
+
<span className="rounded-full bg-error/10 border border-error/20 px-2 py-px text-xs font-semibold text-error tabular-nums">
|
|
452
|
+
{failedRuns.length}
|
|
453
|
+
</span>
|
|
454
|
+
{showFailed ? (
|
|
455
|
+
<ChevronUp className="h-3 w-3 text-error/40 group-hover:text-error/60" />
|
|
456
|
+
) : (
|
|
457
|
+
<ChevronDown className="h-3 w-3 text-error/40 group-hover:text-error/60" />
|
|
458
|
+
)}
|
|
459
|
+
</button>
|
|
460
|
+
{showFailed && (
|
|
461
|
+
<div className="mt-1 opacity-70">
|
|
462
|
+
<VirtualizedRunList runs={failedRuns} maxHeight={400} />
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
{/* Completed runs — collapsible section */}
|
|
469
|
+
{hasSuccessRuns && (
|
|
470
|
+
<div>
|
|
471
|
+
<button
|
|
472
|
+
onClick={(e) => { e.stopPropagation(); setShowCompleted((v) => !v); }}
|
|
473
|
+
className="flex items-center gap-2 py-1.5 text-xs group w-fit"
|
|
474
|
+
>
|
|
475
|
+
<History className="h-3.5 w-3.5 text-foreground-muted/70" />
|
|
476
|
+
<span className="font-semibold text-foreground-muted group-hover:text-foreground-secondary transition-colors">Completed History</span>
|
|
477
|
+
<span className="rounded-full bg-background-secondary border border-border px-2 py-px text-xs font-semibold text-foreground-muted tabular-nums">
|
|
478
|
+
{successRuns.length}
|
|
479
|
+
</span>
|
|
480
|
+
{showCompleted ? (
|
|
481
|
+
<ChevronUp className="h-3 w-3 text-foreground-muted/60 group-hover:text-foreground-muted" />
|
|
482
|
+
) : (
|
|
483
|
+
<ChevronDown className="h-3 w-3 text-foreground-muted/60 group-hover:text-foreground-muted" />
|
|
484
|
+
)}
|
|
485
|
+
</button>
|
|
486
|
+
{showCompleted && (
|
|
487
|
+
<div className="mt-1 opacity-60">
|
|
488
|
+
<VirtualizedRunList runs={successRuns} maxHeight={400} />
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
{totalCount > PAGE_SIZE && (
|
|
496
|
+
<PaginationControls
|
|
497
|
+
currentPage={page}
|
|
498
|
+
totalItems={totalCount}
|
|
499
|
+
itemsPerPage={PAGE_SIZE}
|
|
500
|
+
onPageChange={setPage}
|
|
501
|
+
className="mt-3"
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
})()}
|
|
507
|
+
</Card>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function MiniKpiPill({ icon, count, label, colorClass, bgClass, pulse, active, onClick }: {
|
|
512
|
+
icon: React.ReactNode; count: number; label: string; colorClass: string; bgClass: string; pulse?: boolean; active?: boolean; onClick?: (e: React.MouseEvent) => void;
|
|
513
|
+
}) {
|
|
514
|
+
const isClickable = !!onClick;
|
|
515
|
+
return (
|
|
516
|
+
<div
|
|
517
|
+
role={isClickable ? "button" : undefined}
|
|
518
|
+
tabIndex={isClickable ? 0 : undefined}
|
|
519
|
+
onClick={onClick}
|
|
520
|
+
onKeyDown={isClickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick?.(e as unknown as React.MouseEvent); } } : undefined}
|
|
521
|
+
className={cn(
|
|
522
|
+
"rounded-md px-2.5 py-1.5 flex items-center gap-2 transition-all",
|
|
523
|
+
bgClass,
|
|
524
|
+
isClickable && "cursor-pointer hover:opacity-80",
|
|
525
|
+
active && "ring-2 ring-offset-1 ring-offset-card",
|
|
526
|
+
active && colorClass.replace("text-", "ring-").replace(/\/\d+$/, "/50"),
|
|
527
|
+
)}
|
|
528
|
+
>
|
|
529
|
+
<span className={cn(colorClass, pulse && "animate-pulse")}>{icon}</span>
|
|
530
|
+
<div>
|
|
531
|
+
<p className={cn("text-sm font-bold tabular-nums leading-none", colorClass)}>{count}</p>
|
|
532
|
+
<p className="text-xs leading-tight text-foreground-muted uppercase tracking-wider">{label}</p>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
);
|
|
536
|
+
}
|