9.5 KB284 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { cookies } from "next/headers";
4import { headers } from "next/headers";
5import { groveApiUrl } from "@/lib/utils";
6import { Badge } from "@/app/components/ui/badge";
7import { TimeAgo } from "@/app/components/time-ago";
8import { CanopyLogo } from "@/app/components/canopy-logo";
9import {
10 formatTriggerType,
11 getInvocationRunSlug,
12 getInvocationStatus,
13 groupByInvocation,
14} from "@/lib/canopy-invocations";
15import { CanopyLiveRefresh } from "@/app/components/canopy-live-refresh";
16
17export const metadata: Metadata = {
18 title: "Recent Builds",
19};
20
21interface Run {
22 id: number;
23 pipeline_name: string;
24 status: string;
25 trigger_type?: string | null;
26 commit_id: string | null;
27 commit_message: string | null;
28 trigger_ref?: string | null;
29 started_at?: string | null;
30 duration_ms?: number | null;
31 created_at: string;
32 repo_name: string;
33 owner_name: string;
34}
35
36const statusLabels: Record<string, string> = {
37 pending: "Pending",
38 running: "Running",
39 passed: "Passed",
40 failed: "Failed",
41 cancelled: "Cancelled",
42};
43
44interface RecentRunsResult {
45 runs: Run[];
46 unauthorized: boolean;
47}
48
49async function getRecentRuns(): Promise<RecentRunsResult> {
50 try {
51 const res = await fetch(`${groveApiUrl}/api/canopy/recent-runs?per_repo=20`, {
52 cache: "no-store",
53 });
54 if (res.status === 401) {
55 return { runs: [], unauthorized: true };
56 }
57 if (!res.ok) return { runs: [], unauthorized: false };
58 const data = await res.json();
59 return { runs: data.runs ?? [], unauthorized: false };
60 } catch {
61 return { runs: [], unauthorized: false };
62 }
63}
64
65function groupByRepo(runs: Run[]): { key: string; owner: string; repo: string; runs: Run[]; newestRunId: number }[] {
66 const groups: Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }> = new Map();
67 const sorted = [...runs].sort((a, b) => b.id - a.id);
68 for (const run of sorted) {
69 const key = `${run.owner_name}/${run.repo_name}`;
70 let group = groups.get(key);
71 if (!group) {
72 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
73 groups.set(key, group);
74 }
75 group.runs.push(run);
76 }
77 return Array.from(groups, ([key, g]) => ({ key, ...g })).sort((a, b) => b.newestRunId - a.newestRunId);
78}
79
80export default async function CanopyHomePage() {
81 const cookieStore = await cookies();
82 const signedIn = cookieStore.has("grove_hub_token");
83 const headerStore = await headers();
84 const host =
85 headerStore.get("x-forwarded-host") ??
86 headerStore.get("host") ??
87 "";
88 const protocol = headerStore.get("x-forwarded-proto") ?? "http";
89 const canonicalHost = host.split(",")[0]?.trim() ?? "";
90 const groveHost = canonicalHost.replace(/^(canopy|ring)\./, "");
91 const groveOrigin = groveHost ? `${protocol}://${groveHost}` : "";
92 const loginHref = groveOrigin ? `${groveOrigin}/login` : "/login";
93 const exploreHref = groveOrigin ? `${groveOrigin}/` : "/";
94
95 if (!signedIn) {
96 return (
97 <div className="max-w-3xl mx-auto px-4 py-8">
98 <div
99 className="p-4 sm:p-8 text-center"
100 style={{
101 backgroundColor: "var(--bg-card)",
102 border: "1px solid var(--border-subtle)",
103 }}
104 >
105 <div className="mx-auto mb-4 w-fit opacity-70">
106 <CanopyLogo size={44} />
107 </div>
108 <h1 className="text-lg mb-1">Canopy</h1>
109 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
110 Build and track workflows for your Grove repositories.
111 </p>
112 <div className="flex items-center justify-center gap-2">
113 <Link
114 href={loginHref}
115 className="px-3 py-1.5 text-sm"
116 style={{
117 backgroundColor: "var(--accent)",
118 color: "var(--accent-text)",
119 }}
120 >
121 Sign in
122 </Link>
123 <Link
124 href={exploreHref}
125 className="px-3 py-1.5 text-sm"
126 style={{
127 border: "1px solid var(--border-subtle)",
128 color: "var(--text-secondary)",
129 }}
130 >
131 Explore Grove
132 </Link>
133 </div>
134 </div>
135 </div>
136 );
137 }
138
139 const { runs, unauthorized } = await getRecentRuns();
140
141 if (unauthorized) {
142 return (
143 <div className="max-w-3xl mx-auto px-4 py-8">
144 <div
145 className="p-4 sm:p-8 text-center"
146 style={{
147 backgroundColor: "var(--bg-card)",
148 border: "1px solid var(--border-subtle)",
149 }}
150 >
151 <div className="mx-auto mb-4 w-fit opacity-70">
152 <CanopyLogo size={44} />
153 </div>
154 <h1 className="text-lg mb-1">Canopy</h1>
155 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
156 Build and track workflows for your Grove repositories.
157 </p>
158 <div className="flex items-center justify-center gap-2">
159 <Link
160 href={loginHref}
161 className="px-3 py-1.5 text-sm"
162 style={{
163 backgroundColor: "var(--accent)",
164 color: "var(--accent-text)",
165 }}
166 >
167 Sign in
168 </Link>
169 <Link
170 href={exploreHref}
171 className="px-3 py-1.5 text-sm"
172 style={{
173 border: "1px solid var(--border-subtle)",
174 color: "var(--text-secondary)",
175 }}
176 >
177 Explore Grove
178 </Link>
179 </div>
180 </div>
181 </div>
182 );
183 }
184
185 if (runs.length === 0) {
186 return (
187 <div className="max-w-3xl mx-auto px-4 py-6">
188 <p
189 className="text-sm py-8 text-center"
190 style={{ color: "var(--text-faint)" }}
191 >
192 No builds yet.
193 </p>
194 </div>
195 );
196 }
197
198 const repos = groupByRepo(runs);
199
200 return (
201 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
202 <CanopyLiveRefresh scope="global" />
203 {repos.map((group) => (
204 <div key={group.key}>
205 <div className="mb-2">
206 <Link
207 href={`/${group.owner}/${group.repo}`}
208 className="text-sm font-medium hover:underline"
209 style={{ color: "var(--text-primary)" }}
210 >
211 {group.owner}/{group.repo}
212 </Link>
213 </div>
214 <div>
215 {(() => {
216 const invocations = groupByInvocation(group.runs);
217 return invocations.slice(0, 5).map((invocation) => {
218 const title =
219 invocation.commitMessage ||
220 (invocation.commitId
221 ? invocation.commitId.substring(0, 8)
222 : `${formatTriggerType(invocation.triggerType)} build`);
223
224 const summaryStatus = getInvocationStatus(invocation.runs);
225 const newestRun = invocation.runs[invocation.runs.length - 1];
226 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
227
228 return (
229 <Link
230 key={invocation.key}
231 href={invocationHref}
232 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
233 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
234 >
235 <span className="shrink-0 mt-0.5 sm:hidden">
236 <Badge variant={summaryStatus} compact>
237 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
238 </Badge>
239 </span>
240 <span className="shrink-0 mt-0.5 hidden sm:block">
241 <Badge
242 variant={summaryStatus}
243 style={{ minWidth: "4rem", textAlign: "center" }}
244 >
245 {statusLabels[summaryStatus] ?? summaryStatus}
246 </Badge>
247 </span>
248 <div className="min-w-0 flex-1">
249 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
250 {title}
251 </div>
252 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
253 <span>{formatTriggerType(invocation.triggerType)}</span>
254 {invocation.commitId && (
255 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
256 )}
257 <span>
258 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
259 </span>
260 <span className="ml-auto shrink-0">
261 <TimeAgo date={newestRun.created_at} />
262 </span>
263 </div>
264 </div>
265 </Link>
266 );
267 });
268 })()}
269 </div>
270 <div className="pt-1.5">
271 <Link
272 href={`/${group.owner}/${group.repo}`}
273 className="text-xs hover:underline"
274 style={{ color: "var(--text-muted)" }}
275 >
276 View all builds
277 </Link>
278 </div>
279 </div>
280 ))}
281 </div>
282 );
283}
284