10.8 KB306 lines
Blame
1"use client";
2
3import { useState } from "react";
4import Link from "next/link";
5import { Badge } from "@/app/components/ui/badge";
6import { ElapsedTime } from "@/app/components/elapsed-time";
7import { TimeAgo } from "@/app/components/time-ago";
8import { PipelineRunDetail } from "@/app/components/pipeline-run-detail";
9import { formatTriggerType } from "@/lib/canopy-invocations";
10
11interface Run {
12 id: number;
13 pipeline_name: string;
14 status: string;
15 started_at?: string | null;
16 duration_ms?: number | null;
17 created_at: string;
18}
19
20interface InvocationDetailProps {
21 owner: string;
22 repo: string;
23 runs: Run[];
24 title: string;
25 status: string;
26 triggerType: string | null;
27 commitId: string | null;
28 newestCreatedAt: string;
29}
30
31const statusLabels: Record<string, string> = {
32 pending: "Pending",
33 running: "Running",
34 passed: "Passed",
35 failed: "Failed",
36 cancelled: "Cancelled",
37};
38
39function formatDuration(ms: number | null | undefined): string {
40 if (!ms) return "";
41 if (ms < 1000) return "<1s";
42 const s = Math.floor(ms / 1000);
43 if (s < 60) return `${s}s`;
44 const m = Math.floor(s / 60);
45 const rem = s % 60;
46 return rem > 0 ? `${m}m ${rem}s` : `${m}m`;
47}
48
49export function InvocationDetail({
50 owner,
51 repo,
52 runs,
53 title,
54 status,
55 triggerType,
56 commitId,
57 newestCreatedAt,
58}: InvocationDetailProps) {
59 const defaultRun =
60 runs.find((r) => r.status === "running") ??
61 runs.find((r) => r.status === "pending") ??
62 runs[0];
63 const [selectedRunId, setSelectedRunId] = useState<number | undefined>(
64 defaultRun?.id
65 );
66
67 return (
68 <>
69 {/* Mobile */}
70 <div className="md:hidden px-4 py-6">
71 <div className="mb-4">
72 <div className="flex items-center gap-2 min-w-0">
73 <Badge variant={status} compact>
74 {(statusLabels[status] ?? status).charAt(0)}
75 </Badge>
76 <span className="text-lg truncate">{title}</span>
77 </div>
78 <div
79 className="text-xs flex items-center gap-2 mt-1.5"
80 style={{ color: "var(--text-faint)" }}
81 >
82 <span>{formatTriggerType(triggerType)}</span>
83 {commitId && <span className="font-mono">{commitId.substring(0, 8)}</span>}
84 <span>
85 {runs.length} workflow{runs.length === 1 ? "" : "s"}
86 </span>
87 </div>
88 </div>
89 {runs.map((run) => (
90 <Link
91 key={run.id}
92 href={`/${owner}/${repo}/builds/${run.id}`}
93 className="flex items-center gap-2 py-2.5 px-1 hover-row"
94 style={{
95 borderBottom: "1px solid var(--divide)",
96 textDecoration: "none",
97 color: "inherit",
98 }}
99 >
100 <span className="shrink-0 sm:hidden">
101 <Badge variant={run.status} compact>
102 {(statusLabels[run.status] ?? run.status).charAt(0)}
103 </Badge>
104 </span>
105 <span className="shrink-0 hidden sm:block">
106 <Badge
107 variant={run.status}
108 style={{ minWidth: "4rem", textAlign: "center" }}
109 >
110 {statusLabels[run.status] ?? run.status}
111 </Badge>
112 </span>
113 <div className="min-w-0 flex-1">
114 <div
115 className="text-sm truncate"
116 style={{ color: "var(--accent)" }}
117 >
118 {run.pipeline_name}
119 </div>
120 <div
121 className="flex items-center gap-2 mt-0.5 text-xs"
122 style={{ color: "var(--text-faint)" }}
123 >
124 <span className="font-mono">#{run.id}</span>
125 <span>
126 {run.status === "running" && run.started_at ? (
127 <ElapsedTime since={run.started_at} />
128 ) : (
129 formatDuration(run.duration_ms)
130 )}
131 </span>
132 <span className="ml-auto shrink-0">
133 <TimeAgo date={run.created_at} />
134 </span>
135 </div>
136 </div>
137 </Link>
138 ))}
139 </div>
140
141 {/* Desktop: master-detail */}
142 <div className="hidden md:flex" style={{ height: "100%" }}>
143 {selectedRunId != null ? (
144 <PipelineRunDetail
145 key={selectedRunId}
146 owner={owner}
147 repo={repo}
148 runId={String(selectedRunId)}
149 embedded
150 renderLayout={(stepsColumn, logsColumn, actions) => (
151 <>
152 {/* Left panel: header + workflows + steps */}
153 <div
154 className="shrink-0 flex flex-col"
155 style={{
156 width: "40rem",
157 borderRight: "1px solid var(--border-subtle)",
158 }}
159 >
160 {/* Invocation header */}
161 <div
162 className="px-4 py-3"
163 style={{
164 borderBottom: "1px solid var(--border-subtle)",
165 }}
166 >
167 <div className="flex items-start gap-2 min-w-0">
168 <span className="text-sm font-medium">{title}</span>
169 </div>
170 <div
171 className="flex items-center gap-2 text-xs mt-1"
172 style={{ color: "var(--text-faint)" }}
173 >
174 <span>{formatTriggerType(triggerType)}</span>
175 {commitId && (
176 <>
177 <span>·</span>
178 <span className="font-mono">{commitId.substring(0, 8)}</span>
179 </>
180 )}
181 <span>·</span>
182 <TimeAgo date={newestCreatedAt} />
183 </div>
184 </div>
185
186 {/* Workflows + Steps side by side */}
187 <div className="flex flex-1 min-h-0">
188 <nav
189 className="shrink-0 overflow-y-auto"
190 style={{
191 width: "20rem",
192 borderRight: "1px solid var(--border-subtle)",
193 }}
194 >
195 <div className="px-4 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
196 Workflows
197 </div>
198 {runs.map((run) => {
199 const active = run.id === selectedRunId;
200 const isRunning = run.status === "running";
201 return (
202 <div
203 key={run.id}
204 className="flex items-center gap-2 hover-row py-2 px-4 text-sm"
205 style={{
206 backgroundColor: active ? "var(--bg-inset)" : "transparent",
207 cursor: "pointer",
208 color: active ? "var(--text-primary)" : "var(--text-muted)",
209 fontWeight: active ? 600 : 400,
210 }}
211 onClick={() => setSelectedRunId(run.id)}
212 >
213 <Badge variant={run.status} compact>
214 {(statusLabels[run.status] ?? run.status).charAt(0)}
215 </Badge>
216 <span className="truncate" style={{ flex: 1 }}>
217 {run.pipeline_name}
218 </span>
219 {isRunning && active && actions}
220 <span className="font-mono" style={{ color: "var(--text-faint)", fontSize: "0.7rem", flexShrink: 0 }}>#{run.id}</span>
221 </div>
222 );
223 })}
224 </nav>
225
226 {stepsColumn}
227 </div>
228 </div>
229
230 {/* Logs (full height, right side) */}
231 {logsColumn}
232 </>
233 )}
234 />
235 ) : (
236 /* No run selected — just show left panel */
237 <div
238 className="shrink-0 flex flex-col"
239 style={{
240 width: "40rem",
241 borderRight: "1px solid var(--border-subtle)",
242 }}
243 >
244 <div
245 className="px-4 py-3"
246 style={{
247 borderBottom: "1px solid var(--border-subtle)",
248 backgroundColor: "var(--bg-card)",
249 }}
250 >
251 <div className="flex items-center gap-2 min-w-0">
252 <span className="text-sm font-medium">{title}</span>
253 </div>
254 <div
255 className="flex items-center gap-2 text-xs mt-1"
256 style={{ color: "var(--text-faint)" }}
257 >
258 <span>{formatTriggerType(triggerType)}</span>
259 {commitId && (
260 <>
261 <span>·</span>
262 <span className="font-mono">{commitId.substring(0, 8)}</span>
263 </>
264 )}
265 <span>·</span>
266 <TimeAgo date={newestCreatedAt} />
267 </div>
268 </div>
269 <nav className="flex-1 overflow-y-auto">
270 <div className="px-4 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
271 Workflows
272 </div>
273 {runs.map((run) => {
274 const active = run.id === selectedRunId;
275 return (
276 <button
277 key={run.id}
278 onClick={() => setSelectedRunId(run.id)}
279 className="w-full text-left py-2 px-4 hover-row"
280 style={{
281 backgroundColor: active ? "var(--bg-inset)" : "transparent",
282 border: "none",
283 cursor: "pointer",
284 font: "inherit",
285 color: active ? "var(--text-primary)" : "var(--text-muted)",
286 fontWeight: active ? 600 : 400,
287 }}
288 >
289 <div className="flex items-center gap-2 text-sm">
290 <Badge variant={run.status} compact>
291 {(statusLabels[run.status] ?? run.status).charAt(0)}
292 </Badge>
293 <span className="truncate" style={{ flex: 1 }}>{run.pipeline_name}</span>
294 <span className="font-mono" style={{ color: "var(--text-faint)", fontSize: "0.7rem" }}>#{run.id}</span>
295 </div>
296 </button>
297 );
298 })}
299 </nav>
300 </div>
301 )}
302 </div>
303 </>
304 );
305}
306