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";
a011f1e5import { groveApiUrl } from "@/lib/utils";
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 {
721afa651 const res = await fetch(`${groveApiUrl}/api/canopy/recent-runs?per_repo=20`, {
1da987452 cache: "no-store",
1da987453 });
73fdc9e54 if (res.status === 401) {
73fdc9e55 return { runs: [], unauthorized: true };
73fdc9e56 }
73fdc9e57 if (!res.ok) return { runs: [], unauthorized: false };
1da987458 const data = await res.json();
73fdc9e59 return { runs: data.runs ?? [], unauthorized: false };
1da987460 } catch {
73fdc9e61 return { runs: [], unauthorized: false };
1da987462 }
1da987463}
1da987464
9d879c065function groupByRepo(runs: Run[]): { key: string; owner: string; repo: string; runs: Run[]; newestRunId: number }[] {
9d879c066 const groups: Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }> = new Map();
9d879c067 const sorted = [...runs].sort((a, b) => b.id - a.id);
9d879c068 for (const run of sorted) {
a011f1e69 const key = `${run.owner_name}/${run.repo_name}`;
a011f1e70 let group = groups.get(key);
a011f1e71 if (!group) {
9d879c072 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
a011f1e73 groups.set(key, group);
a011f1e74 }
a011f1e75 group.runs.push(run);
a011f1e76 }
9d879c077 return Array.from(groups, ([key, g]) => ({ key, ...g })).sort((a, b) => b.newestRunId - a.newestRunId);
a011f1e78}
a011f1e79
1da987480export default async function CanopyHomePage() {
73fdc9e81 const cookieStore = await cookies();
73fdc9e82 const signedIn = cookieStore.has("grove_hub_token");
a33b2b683 const headerStore = await headers();
a33b2b684 const host =
a33b2b685 headerStore.get("x-forwarded-host") ??
a33b2b686 headerStore.get("host") ??
a33b2b687 "";
a33b2b688 const protocol = headerStore.get("x-forwarded-proto") ?? "http";
a33b2b689 const canonicalHost = host.split(",")[0]?.trim() ?? "";
a33b2b690 const groveHost = canonicalHost.replace(/^(canopy|ring)\./, "");
a33b2b691 const groveOrigin = groveHost ? `${protocol}://${groveHost}` : "";
a33b2b692 const loginHref = groveOrigin ? `${groveOrigin}/login` : "/login";
a33b2b693 const exploreHref = groveOrigin ? `${groveOrigin}/` : "/";
73fdc9e94
73fdc9e95 if (!signedIn) {
73fdc9e96 return (
73fdc9e97 <div className="max-w-3xl mx-auto px-4 py-8">
73fdc9e98 <div
8a2c7d499 className="p-4 sm:p-8 text-center"
73fdc9e100 style={{
73fdc9e101 backgroundColor: "var(--bg-card)",
73fdc9e102 border: "1px solid var(--border-subtle)",
73fdc9e103 }}
73fdc9e104 >
73fdc9e105 <div className="mx-auto mb-4 w-fit opacity-70">
73fdc9e106 <CanopyLogo size={44} />
73fdc9e107 </div>
73fdc9e108 <h1 className="text-lg mb-1">Canopy</h1>
73fdc9e109 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
73fdc9e110 Build and track workflows for your Grove repositories.
73fdc9e111 </p>
73fdc9e112 <div className="flex items-center justify-center gap-2">
73fdc9e113 <Link
a33b2b6114 href={loginHref}
73fdc9e115 className="px-3 py-1.5 text-sm"
73fdc9e116 style={{
73fdc9e117 backgroundColor: "var(--accent)",
73fdc9e118 color: "var(--accent-text)",
73fdc9e119 }}
73fdc9e120 >
73fdc9e121 Sign in
73fdc9e122 </Link>
73fdc9e123 <Link
a33b2b6124 href={exploreHref}
73fdc9e125 className="px-3 py-1.5 text-sm"
73fdc9e126 style={{
73fdc9e127 border: "1px solid var(--border-subtle)",
73fdc9e128 color: "var(--text-secondary)",
73fdc9e129 }}
73fdc9e130 >
73fdc9e131 Explore Grove
73fdc9e132 </Link>
73fdc9e133 </div>
73fdc9e134 </div>
73fdc9e135 </div>
73fdc9e136 );
73fdc9e137 }
73fdc9e138
73fdc9e139 const { runs, unauthorized } = await getRecentRuns();
73fdc9e140
73fdc9e141 if (unauthorized) {
73fdc9e142 return (
73fdc9e143 <div className="max-w-3xl mx-auto px-4 py-8">
73fdc9e144 <div
8a2c7d4145 className="p-4 sm:p-8 text-center"
73fdc9e146 style={{
73fdc9e147 backgroundColor: "var(--bg-card)",
73fdc9e148 border: "1px solid var(--border-subtle)",
73fdc9e149 }}
73fdc9e150 >
73fdc9e151 <div className="mx-auto mb-4 w-fit opacity-70">
73fdc9e152 <CanopyLogo size={44} />
73fdc9e153 </div>
73fdc9e154 <h1 className="text-lg mb-1">Canopy</h1>
73fdc9e155 <p className="text-sm mb-4" style={{ color: "var(--text-faint)" }}>
73fdc9e156 Build and track workflows for your Grove repositories.
73fdc9e157 </p>
73fdc9e158 <div className="flex items-center justify-center gap-2">
73fdc9e159 <Link
a33b2b6160 href={loginHref}
73fdc9e161 className="px-3 py-1.5 text-sm"
73fdc9e162 style={{
73fdc9e163 backgroundColor: "var(--accent)",
73fdc9e164 color: "var(--accent-text)",
73fdc9e165 }}
73fdc9e166 >
73fdc9e167 Sign in
73fdc9e168 </Link>
73fdc9e169 <Link
a33b2b6170 href={exploreHref}
73fdc9e171 className="px-3 py-1.5 text-sm"
73fdc9e172 style={{
73fdc9e173 border: "1px solid var(--border-subtle)",
73fdc9e174 color: "var(--text-secondary)",
73fdc9e175 }}
73fdc9e176 >
73fdc9e177 Explore Grove
73fdc9e178 </Link>
73fdc9e179 </div>
73fdc9e180 </div>
73fdc9e181 </div>
73fdc9e182 );
73fdc9e183 }
1da9874184
1da9874185 if (runs.length === 0) {
1da9874186 return (
1da9874187 <div className="max-w-3xl mx-auto px-4 py-6">
1da9874188 <p
1da9874189 className="text-sm py-8 text-center"
1da9874190 style={{ color: "var(--text-faint)" }}
1da9874191 >
087adca192 No builds yet.
1da9874193 </p>
1da9874194 </div>
1da9874195 );
1da9874196 }
da0f651197
a011f1e198 const repos = groupByRepo(runs);
a011f1e199
da0f651200 return (
a011f1e201 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
5bcd5db202 <CanopyLiveRefresh scope="global" />
a011f1e203 {repos.map((group) => (
a011f1e204 <div key={group.key}>
a011f1e205 <div className="mb-2">
a011f1e206 <Link
a011f1e207 href={`/${group.owner}/${group.repo}`}
a011f1e208 className="text-sm font-medium hover:underline"
a011f1e209 style={{ color: "var(--text-primary)" }}
1da9874210 >
a011f1e211 {group.owner}/{group.repo}
a011f1e212 </Link>
a011f1e213 </div>
8a2c7d4214 <div>
8a2c7d4215 {(() => {
8a2c7d4216 const invocations = groupByInvocation(group.runs);
8a2c7d4217 return invocations.slice(0, 5).map((invocation) => {
9d879c0218 const title =
9d879c0219 invocation.commitMessage ||
9d879c0220 (invocation.commitId
9d879c0221 ? invocation.commitId.substring(0, 8)
9d879c0222 : `${formatTriggerType(invocation.triggerType)} build`);
9d879c0223
9d879c0224 const summaryStatus = getInvocationStatus(invocation.runs);
9d879c0225 const newestRun = invocation.runs[invocation.runs.length - 1];
8a2c7d4226 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
9d879c0227
9d879c0228 return (
8a2c7d4229 <Link
8a2c7d4230 key={invocation.key}
8a2c7d4231 href={invocationHref}
8a2c7d4232 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
8a2c7d4233 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
8a2c7d4234 >
8a2c7d4235 <span className="shrink-0 mt-0.5 sm:hidden">
8a2c7d4236 <Badge variant={summaryStatus} compact>
8a2c7d4237 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
8a2c7d4238 </Badge>
8a2c7d4239 </span>
8a2c7d4240 <span className="shrink-0 mt-0.5 hidden sm:block">
8a2c7d4241 <Badge
8a2c7d4242 variant={summaryStatus}
8a2c7d4243 style={{ minWidth: "4rem", textAlign: "center" }}
8a2c7d4244 >
8a2c7d4245 {statusLabels[summaryStatus] ?? summaryStatus}
8a2c7d4246 </Badge>
8a2c7d4247 </span>
8a2c7d4248 <div className="min-w-0 flex-1">
8a2c7d4249 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
8a2c7d4250 {title}
8a2c7d4251 </div>
8a2c7d4252 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
8a2c7d4253 <span>{formatTriggerType(invocation.triggerType)}</span>
8a2c7d4254 {invocation.commitId && (
8a2c7d4255 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
8a2c7d4256 )}
8a2c7d4257 <span>
8a2c7d4258 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
8a2c7d4259 </span>
8a2c7d4260 <span className="ml-auto shrink-0">
8a2c7d4261 <TimeAgo date={newestRun.created_at} />
8a2c7d4262 </span>
8a2c7d4263 </div>
8a2c7d4264 </div>
8a2c7d4265 </Link>
9d879c0266 );
8a2c7d4267 });
8a2c7d4268 })()}
8a2c7d4269 </div>
a011f1e270 <div className="pt-1.5">
a011f1e271 <Link
a011f1e272 href={`/${group.owner}/${group.repo}`}
a011f1e273 className="text-xs hover:underline"
a011f1e274 style={{ color: "var(--text-muted)" }}
a011f1e275 >
087adca276 View all builds
a011f1e277 </Link>
a011f1e278 </div>
a011f1e279 </div>
a011f1e280 ))}
da0f651281 </div>
da0f651282 );
da0f651283}