web/app/canopy/page.tsxblame
View source
1da98741import type { Metadata } from "next";
1da98742import Link from "next/link";
73fdc9e3import { cookies } from "next/headers";
a33b2b64import { headers } from "next/headers";
bc1b2ba5import { serverFetch } from "@/lib/server-fetch";
1da98746import { Badge } from "@/app/components/ui/badge";
a011f1e7import { TimeAgo } from "@/app/components/time-ago";
73fdc9e8import { CanopyLogo } from "@/app/components/canopy-logo";
9d879c09import {
9d879c010 formatTriggerType,
9d879c011 getInvocationRunSlug,
9d879c012 getInvocationStatus,
9d879c013 groupByInvocation,
9d879c014} from "@/lib/canopy-invocations";
5bcd5db15import { CanopyLiveRefresh } from "@/app/components/canopy-live-refresh";
1da987416
1da987417export const metadata: Metadata = {
1da987418 title: "Recent Builds",
1da987419};
1da987420
1da987421interface Run {
1da987422 id: number;
1da987423 pipeline_name: string;
1da987424 status: string;
9d879c025 trigger_type?: string | null;
1da987426 commit_id: string | null;
1da987427 commit_message: string | null;
9d879c028 trigger_ref?: string | null;
9d879c029 started_at?: string | null;
9d879c030 duration_ms?: number | null;
1da987431 created_at: string;
1da987432 repo_name: string;
1da987433 owner_name: string;
1da987434}
1da987435
1da987436const statusLabels: Record<string, string> = {
1da987437 pending: "Pending",
1da987438 running: "Running",
1da987439 passed: "Passed",
1da987440 failed: "Failed",
1da987441 cancelled: "Cancelled",
1da987442};
1da987443
73fdc9e44interface RecentRunsResult {
73fdc9e45 runs: Run[];
73fdc9e46 unauthorized: boolean;
73fdc9e47}
73fdc9e48
73fdc9e49async function getRecentRuns(): Promise<RecentRunsResult> {
1da987450 try {
bc1b2ba51 // Note: this page needs to distinguish 401 (signed out) from
bc1b2ba52 // other failures, so it uses serverFetch directly rather than the
bc1b2ba53 // higher-level helpers in grove-api.ts.
bc1b2ba54 const res = await serverFetch(`/api/canopy/recent-runs?per_repo=20`);
bc1b2ba55 if (res.status === 401) return { runs: [], unauthorized: true };
73fdc9e56 if (!res.ok) return { runs: [], unauthorized: false };
1da987457 const data = await res.json();
73fdc9e58 return { runs: data.runs ?? [], unauthorized: false };
1da987459 } catch {
73fdc9e60 return { runs: [], unauthorized: false };
1da987461 }
1da987462}
1da987463
9d879c064function groupByRepo(runs: Run[]): { key: string; owner: string; repo: string; runs: Run[]; newestRunId: number }[] {
9d879c065 const groups: Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }> = new Map();
9d879c066 const sorted = [...runs].sort((a, b) => b.id - a.id);
9d879c067 for (const run of sorted) {
a011f1e68 const key = `${run.owner_name}/${run.repo_name}`;
a011f1e69 let group = groups.get(key);
a011f1e70 if (!group) {
9d879c071 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
a011f1e72 groups.set(key, group);
a011f1e73 }
a011f1e74 group.runs.push(run);
a011f1e75 }
9d879c076 return Array.from(groups, ([key, g]) => ({ key, ...g })).sort((a, b) => b.newestRunId - a.newestRunId);
a011f1e77}
a011f1e78
1da987479export default async function CanopyHomePage() {
73fdc9e80 const cookieStore = await cookies();
73fdc9e81 const signedIn = cookieStore.has("grove_hub_token");
a33b2b682 const headerStore = await headers();
a33b2b683 const host =
a33b2b684 headerStore.get("x-forwarded-host") ??
a33b2b685 headerStore.get("host") ??
a33b2b686 "";
a33b2b687 const protocol = headerStore.get("x-forwarded-proto") ?? "http";
a33b2b688 const canonicalHost = host.split(",")[0]?.trim() ?? "";
a33b2b689 const groveHost = canonicalHost.replace(/^(canopy|ring)\./, "");
a33b2b690 const groveOrigin = groveHost ? `${protocol}://${groveHost}` : "";
a33b2b691 const loginHref = groveOrigin ? `${groveOrigin}/login` : "/login";
a33b2b692 const exploreHref = groveOrigin ? `${groveOrigin}/` : "/";
73fdc9e93
73fdc9e94 if (!signedIn) {
73fdc9e95 return (
73fdc9e96 <div className="max-w-3xl mx-auto px-4 py-8">
73fdc9e97 <div
8a2c7d498 className="p-4 sm:p-8 text-center"
73fdc9e99 style={{
73fdc9e100 backgroundColor: "var(--bg-card)",
73fdc9e101 border: "1px solid var(--border-subtle)",
73fdc9e102 }}
73fdc9e103 >
73fdc9e104 <div className="mx-auto mb-4 w-fit opacity-70">
73fdc9e105 <CanopyLogo size={44} />
73fdc9e106 </div>
73fdc9e107 <h1 className="text-lg mb-1">Canopy</h1>
73fdc9e108 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
73fdc9e109 Build and track workflows for your Grove repositories.
73fdc9e110 </p>
73fdc9e111 <div className="flex items-center justify-center gap-2">
73fdc9e112 <Link
a33b2b6113 href={loginHref}
73fdc9e114 className="px-3 py-1.5 text-sm"
73fdc9e115 style={{
73fdc9e116 backgroundColor: "var(--accent)",
73fdc9e117 color: "var(--accent-text)",
73fdc9e118 }}
73fdc9e119 >
73fdc9e120 Sign in
73fdc9e121 </Link>
73fdc9e122 <Link
a33b2b6123 href={exploreHref}
73fdc9e124 className="px-3 py-1.5 text-sm"
73fdc9e125 style={{
73fdc9e126 border: "1px solid var(--border-subtle)",
73fdc9e127 color: "var(--text-secondary)",
73fdc9e128 }}
73fdc9e129 >
73fdc9e130 Explore Grove
73fdc9e131 </Link>
73fdc9e132 </div>
73fdc9e133 </div>
73fdc9e134 </div>
73fdc9e135 );
73fdc9e136 }
73fdc9e137
73fdc9e138 const { runs, unauthorized } = await getRecentRuns();
73fdc9e139
73fdc9e140 if (unauthorized) {
73fdc9e141 return (
73fdc9e142 <div className="max-w-3xl mx-auto px-4 py-8">
73fdc9e143 <div
8a2c7d4144 className="p-4 sm:p-8 text-center"
73fdc9e145 style={{
73fdc9e146 backgroundColor: "var(--bg-card)",
73fdc9e147 border: "1px solid var(--border-subtle)",
73fdc9e148 }}
73fdc9e149 >
73fdc9e150 <div className="mx-auto mb-4 w-fit opacity-70">
73fdc9e151 <CanopyLogo size={44} />
73fdc9e152 </div>
73fdc9e153 <h1 className="text-lg mb-1">Canopy</h1>
73fdc9e154 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
73fdc9e155 Build and track workflows for your Grove repositories.
73fdc9e156 </p>
73fdc9e157 <div className="flex items-center justify-center gap-2">
73fdc9e158 <Link
a33b2b6159 href={loginHref}
73fdc9e160 className="px-3 py-1.5 text-sm"
73fdc9e161 style={{
73fdc9e162 backgroundColor: "var(--accent)",
73fdc9e163 color: "var(--accent-text)",
73fdc9e164 }}
73fdc9e165 >
73fdc9e166 Sign in
73fdc9e167 </Link>
73fdc9e168 <Link
a33b2b6169 href={exploreHref}
73fdc9e170 className="px-3 py-1.5 text-sm"
73fdc9e171 style={{
73fdc9e172 border: "1px solid var(--border-subtle)",
73fdc9e173 color: "var(--text-secondary)",
73fdc9e174 }}
73fdc9e175 >
73fdc9e176 Explore Grove
73fdc9e177 </Link>
73fdc9e178 </div>
73fdc9e179 </div>
73fdc9e180 </div>
73fdc9e181 );
73fdc9e182 }
1da9874183
1da9874184 if (runs.length === 0) {
1da9874185 return (
1da9874186 <div className="max-w-3xl mx-auto px-4 py-6">
1da9874187 <p
1da9874188 className="text-sm py-8 text-center"
1da9874189 style={{ color: "var(--text-faint)" }}
1da9874190 >
087adca191 No builds yet.
1da9874192 </p>
1da9874193 </div>
1da9874194 );
1da9874195 }
da0f651196
a011f1e197 const repos = groupByRepo(runs);
a011f1e198
da0f651199 return (
a011f1e200 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
5bcd5db201 <CanopyLiveRefresh scope="global" />
a011f1e202 {repos.map((group) => (
a011f1e203 <div key={group.key}>
a011f1e204 <div className="mb-2">
a011f1e205 <Link
a011f1e206 href={`/${group.owner}/${group.repo}`}
a011f1e207 className="text-sm font-medium hover:underline"
a011f1e208 style={{ color: "var(--text-primary)" }}
1da9874209 >
a011f1e210 {group.owner}/{group.repo}
a011f1e211 </Link>
a011f1e212 </div>
8a2c7d4213 <div>
8a2c7d4214 {(() => {
8a2c7d4215 const invocations = groupByInvocation(group.runs);
8a2c7d4216 return invocations.slice(0, 5).map((invocation) => {
9d879c0217 const title =
9d879c0218 invocation.commitMessage ||
9d879c0219 (invocation.commitId
9d879c0220 ? invocation.commitId.substring(0, 8)
9d879c0221 : `${formatTriggerType(invocation.triggerType)} build`);
9d879c0222
9d879c0223 const summaryStatus = getInvocationStatus(invocation.runs);
9d879c0224 const newestRun = invocation.runs[invocation.runs.length - 1];
8a2c7d4225 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
9d879c0226
9d879c0227 return (
8a2c7d4228 <Link
8a2c7d4229 key={invocation.key}
8a2c7d4230 href={invocationHref}
8a2c7d4231 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
8a2c7d4232 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
8a2c7d4233 >
8a2c7d4234 <span className="shrink-0 mt-0.5 sm:hidden">
8a2c7d4235 <Badge variant={summaryStatus} compact>
8a2c7d4236 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
8a2c7d4237 </Badge>
8a2c7d4238 </span>
8a2c7d4239 <span className="shrink-0 mt-0.5 hidden sm:block">
8a2c7d4240 <Badge
8a2c7d4241 variant={summaryStatus}
8a2c7d4242 style={{ minWidth: "4rem", textAlign: "center" }}
8a2c7d4243 >
8a2c7d4244 {statusLabels[summaryStatus] ?? summaryStatus}
8a2c7d4245 </Badge>
8a2c7d4246 </span>
8a2c7d4247 <div className="min-w-0 flex-1">
8a2c7d4248 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
8a2c7d4249 {title}
8a2c7d4250 </div>
8a2c7d4251 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
8a2c7d4252 <span>{formatTriggerType(invocation.triggerType)}</span>
8a2c7d4253 {invocation.commitId && (
8a2c7d4254 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
8a2c7d4255 )}
8a2c7d4256 <span>
8a2c7d4257 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
8a2c7d4258 </span>
8a2c7d4259 <span className="ml-auto shrink-0">
8a2c7d4260 <TimeAgo date={newestRun.created_at} />
8a2c7d4261 </span>
8a2c7d4262 </div>
8a2c7d4263 </div>
8a2c7d4264 </Link>
9d879c0265 );
8a2c7d4266 });
8a2c7d4267 })()}
8a2c7d4268 </div>
a011f1e269 <div className="pt-1.5">
a011f1e270 <Link
a011f1e271 href={`/${group.owner}/${group.repo}`}
a011f1e272 className="text-xs hover:underline"
a011f1e273 style={{ color: "var(--text-muted)" }}
a011f1e274 >
087adca275 View all builds
a011f1e276 </Link>
a011f1e277 </div>
a011f1e278 </div>
a011f1e279 ))}
da0f651280 </div>
da0f651281 );
da0f651282}