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