Skip to content
File

Blob: src/client/pages/project-detail-page.tsx

typescript221 lines
1import type { ProjectDetail } from "@/contracts";
2import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { Play, RefreshCw } from "lucide-react";
4import { useParams } from "react-router-dom";
5import { ProjectMetadataCard, RunRow, WebhookCard } from "@/client/components";
6import { Breadcrumbs, Button, EmptyState, ErrorBanner, Skeleton } from "@/client/components/ui";
7import { formatApiError, getApiClient, queryKeys } from "@/client/lib";
8import { useToast } from "@/client/toast";
9 
10const RUN_PAGE_LIMIT = 20;
11 
12const hasRunActivity = (detail: ProjectDetail | undefined): boolean =>
13 detail !== undefined && (detail.activeRun !== null || detail.pendingRuns.length > 0);
14 
15const ProjectDetailSkeleton = () => (
16 <div className="animate-slide-up space-y-5">
17 <Skeleton className="h-5 w-48" />
18 <div className="grid gap-5 lg:grid-cols-[minmax(230px,400px)_minmax(0,1fr)]">
19 <div className="space-y-4">
20 <div className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-5">
21 <Skeleton className="h-3 w-24" />
22 <Skeleton className="mt-3 h-6 w-44" />
23 <Skeleton className="mt-2 h-4 w-32" />
24 <div className="mt-4 space-y-2">
25 {Array.from({ length: 4 }, (_, i) => (
26 <Skeleton key={i} className="h-4 w-36" />
27 ))}
28 </div>
29 </div>
30 </div>
31 <div className="space-y-4">
32 <Skeleton className="h-5 w-32" />
33 {Array.from({ length: 3 }, (_, i) => (
34 <div key={i} className="rounded-2xl border border-zinc-800/60 bg-zinc-900/50 p-4">
35 <div className="flex items-center gap-4">
36 <Skeleton className="h-6 w-16 rounded-full" />
37 <div className="flex-1">
38 <Skeleton className="h-4 w-32" />
39 <Skeleton className="mt-1.5 h-3.5 w-24" />
40 </div>
41 </div>
42 </div>
43 ))}
44 </div>
45 </div>
46 </div>
47);
48 
49export const ProjectDetailPage = () => {
50 const { projectId } = useParams<{ projectId: string }>();
51 const { pushToast } = useToast();
52 const queryClient = useQueryClient();
53 const resolvedProjectId = projectId ?? "";
54 
55 const detailQuery = useQuery({
56 queryKey: queryKeys.projectDetail(resolvedProjectId),
57 queryFn: () => getApiClient().getProjectDetail(resolvedProjectId),
58 enabled: resolvedProjectId.length > 0,
59 refetchInterval: (query) => (hasRunActivity(query.state.data) ? 7_000 : false),
60 });
61 
62 const runsQuery = useInfiniteQuery({
63 queryKey: queryKeys.projectRuns(resolvedProjectId),
64 queryFn: ({ pageParam }) =>
65 getApiClient().getProjectRuns(resolvedProjectId, {
66 limit: RUN_PAGE_LIMIT,
67 cursor: pageParam ?? undefined,
68 }),
69 enabled: resolvedProjectId.length > 0,
70 getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
71 initialPageParam: null as string | null,
72 refetchInterval: hasRunActivity(detailQuery.data) ? 7_000 : false,
73 });
74 
75 const triggerRunMutation = useMutation({
76 mutationFn: () => getApiClient().triggerRun(resolvedProjectId),
77 onSuccess: async (response) => {
78 pushToast({ tone: "success", title: "Run triggered", message: `Run ${response.runId} queued.` });
79 await Promise.all([
80 queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(resolvedProjectId) }),
81 queryClient.invalidateQueries({ queryKey: queryKeys.projectRuns(resolvedProjectId) }),
82 queryClient.invalidateQueries({ queryKey: queryKeys.projectsRoot() }),
83 ]);
84 },
85 onError: (reason) => {
86 pushToast({ tone: "error", title: "Trigger failed", message: formatApiError(reason) });
87 },
88 });
89 
90 const handleRefresh = async () => {
91 await Promise.all([detailQuery.refetch(), runsQuery.refetch()]);
92 };
93 
94 const pageTitle = detailQuery.data?.project.name ?? projectId?.slice(0, 8) ?? "";
95 
96 if (!projectId) {
97 return (
98 <div className="space-y-4">
99 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
100 <ErrorBanner message="Missing project id." />
101 </div>
102 );
103 }
104 
105 if (detailQuery.isPending || runsQuery.isPending) {
106 return (
107 <>
108 <h1 className="sr-only">{pageTitle}</h1>
109 <ProjectDetailSkeleton />
110 </>
111 );
112 }
113 
114 if (detailQuery.isError && !detailQuery.data) {
115 return (
116 <div className="space-y-4">
117 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: "Error" }]} />
118 <ErrorBanner message={formatApiError(detailQuery.error)} />
119 </div>
120 );
121 }
122 
123 if (!detailQuery.data) return null;
124 
125 const { project } = detailQuery.data;
126 const runs = runsQuery.data?.pages.flatMap((page) => page.runs) ?? [];
127 const runsError = runsQuery.isError ? formatApiError(runsQuery.error) : null;
128 const loadingMore = runsQuery.isFetchingNextPage;
129 const triggering = triggerRunMutation.isPending;
130 
131 return (
132 <div className="animate-slide-up space-y-5">
133 <Breadcrumbs items={[{ label: "Projects", href: "/app/projects" }, { label: project.name }]} />
134
135 <div className="grid gap-5 lg:grid-cols-[minmax(230px,400px)_minmax(0,1fr)]">
136 <div className="space-y-4">
137 <ProjectMetadataCard project={project} settingsHref={`/app/projects/${projectId}/settings`} />
138 <WebhookCard projectId={projectId} project={project} />
139 </div>
140
141 <div className="space-y-4">
142 <div className="flex items-center justify-between">
143 <div>
144 <p className="text-xs uppercase tracking-[0.24em] text-zinc-500">Run history</p>
145 <h2 className="mt-1 font-display text-lg font-semibold text-zinc-100">Recent runs</h2>
146 </div>
147 <div className="flex items-center gap-2">
148 <Button
149 variant="primary"
150 size="sm"
151 disabled={triggering}
152 loading={triggering}
153 icon={!triggering ? <Play className="h-3.5 w-3.5" /> : undefined}
154 onClick={() => triggerRunMutation.mutate()}
155 >
156 Trigger Run
157 </Button>
158 <Button
159 variant="ghost"
160 size="sm"
161 icon={<RefreshCw className="h-3.5 w-3.5" />}
162 aria-label="Refresh"
163 onClick={() => {
164 void handleRefresh();
165 }}
166 />
167 </div>
168 </div>
169
170 {detailQuery.isError ? <ErrorBanner message={formatApiError(detailQuery.error)} /> : null}
171
172 {runsError && runs.length === 0 ? <ErrorBanner message={runsError} /> : null}
173
174 {runs.length === 0 && !runsError ? (
175 <EmptyState
176 icon={<Play className="h-6 w-6" />}
177 title="No runs yet"
178 description="Trigger a run to get started."
179 />
180 ) : (
181 <div className="space-y-2">
182 {runs.map((run) => (
183 <RunRow key={run.id} run={run} />
184 ))}
185 </div>
186 )}
187
188 {runsQuery.hasNextPage ? (
189 <Button
190 variant="secondary"
191 className="w-full"
192 disabled={loadingMore}
193 loading={loadingMore}
194 onClick={() => {
195 void runsQuery.fetchNextPage();
196 }}
197 >
198 Load more
199 </Button>
200 ) : null}
201
202 {runsError && runs.length > 0 ? (
203 <div className="space-y-2">
204 <ErrorBanner message={runsError} />
205 <Button
206 variant="secondary"
207 className="w-full"
208 onClick={() => {
209 void runsQuery.fetchNextPage();
210 }}
211 >
212 Retry
213 </Button>
214 </div>
215 ) : null}
216 </div>
217 </div>
218 </div>
219 );
220};