web/app/canopy/%5Bowner%5D/%5Brepo%5D/page.tsxblame
View source
da0f6511import type { Metadata } from "next";
fe3b5092import Link from "next/link";
da0f6513import { groveApiUrl } from "@/lib/utils";
fe3b5094import { Badge } from "@/app/components/ui/badge";
fe3b5095import { TimeAgo } from "@/app/components/time-ago";
fe3b5096import {
fe3b5097 formatTriggerType,
fe3b5098 getInvocationRunSlug,
fe3b5099 getInvocationStatus,
fe3b50910 groupByInvocation,
fe3b50911} from "@/lib/canopy-invocations";
5bcd5db12import { CanopyLiveRefresh } from "@/app/components/canopy-live-refresh";
da0f65113
da0f65114interface Props {
da0f65115 params: Promise<{ owner: string; repo: string }>;
da0f65116}
da0f65117
da0f65118export async function generateMetadata({ params }: Props): Promise<Metadata> {
86450dc19 const { repo } = await params;
86450dc20 return { title: `Canopy · ${repo}` };
da0f65121}
da0f65122
fe3b50923interface Run {
fe3b50924 id: number;
fe3b50925 pipeline_name: string;
fe3b50926 status: string;
fe3b50927 trigger_type?: string | null;
fe3b50928 commit_id: string | null;
fe3b50929 commit_message: string | null;
fe3b50930 trigger_ref?: string | null;
fe3b50931 started_at?: string | null;
fe3b50932 duration_ms?: number | null;
fe3b50933 created_at: string;
fe3b50934 repo_name: string;
fe3b50935 owner_name: string;
fe3b50936}
fe3b50937
fe3b50938const statusLabels: Record<string, string> = {
fe3b50939 pending: "Pending",
fe3b50940 running: "Running",
fe3b50941 passed: "Passed",
fe3b50942 failed: "Failed",
fe3b50943 cancelled: "Cancelled",
fe3b50944};
fe3b50945
fe3b50946async function getRepoRuns(owner: string, repo: string): Promise<Run[]> {
da0f65147 try {
da0f65148 const res = await fetch(
fe3b50949 `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`,
fe3b50950 { cache: "no-store" },
da0f65151 );
fe3b50952 if (!res.ok) return [];
fe3b50953 const data = await res.json();
fe3b50954 const runs: Run[] = data.runs ?? [];
fe3b50955 return runs.filter((r) => r.repo_name === repo);
da0f65156 } catch {
fe3b50957 return [];
da0f65158 }
da0f65159}
da0f65160
da0f65161export default async function CanopyRepoPage({ params }: Props) {
da0f65162 const { owner, repo } = await params;
fe3b50963 const runs = await getRepoRuns(owner, repo);
a011f1e64
fe3b50965 if (runs.length === 0) {
da0f65166 return (
da0f65167 <div className="max-w-3xl mx-auto px-4 py-6">
da0f65168 <p
da0f65169 className="text-sm py-8 text-center"
da0f65170 style={{ color: "var(--text-faint)" }}
da0f65171 >
087adca72 No builds yet. Add a{" "}
da0f65173 <code className="text-xs">.canopy/*.yml</code> file to get started.
da0f65174 </p>
da0f65175 </div>
da0f65176 );
da0f65177 }
da0f65178
fe3b50979 const invocations = groupByInvocation(runs);
fe3b50980
da0f65181 return (
da0f65182 <div className="max-w-3xl mx-auto px-4 py-6">
5bcd5db83 <CanopyLiveRefresh scope="repo" owner={owner} repo={repo} />
fe3b50984 <div>
fe3b50985 {invocations.map((invocation) => {
fe3b50986 const title =
fe3b50987 invocation.commitMessage ||
fe3b50988 (invocation.commitId
fe3b50989 ? invocation.commitId.substring(0, 8)
fe3b50990 : `${formatTriggerType(invocation.triggerType)} build`);
fe3b50991
fe3b50992 const summaryStatus = getInvocationStatus(invocation.runs);
fe3b50993 const newestRun = invocation.runs[invocation.runs.length - 1];
fe3b50994 const invocationHref = `/${owner}/${repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
fe3b50995
fe3b50996 return (
fe3b50997 <Link
fe3b50998 key={invocation.key}
fe3b50999 href={invocationHref}
fe3b509100 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
fe3b509101 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
fe3b509102 >
fe3b509103 <span className="shrink-0 mt-0.5 sm:hidden">
fe3b509104 <Badge variant={summaryStatus} compact>
fe3b509105 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
fe3b509106 </Badge>
fe3b509107 </span>
fe3b509108 <span className="shrink-0 mt-0.5 hidden sm:block">
fe3b509109 <Badge
fe3b509110 variant={summaryStatus}
fe3b509111 style={{ minWidth: "4rem", textAlign: "center" }}
fe3b509112 >
fe3b509113 {statusLabels[summaryStatus] ?? summaryStatus}
fe3b509114 </Badge>
fe3b509115 </span>
fe3b509116 <div className="min-w-0 flex-1">
fe3b509117 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
fe3b509118 {title}
fe3b509119 </div>
fe3b509120 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
fe3b509121 <span>{formatTriggerType(invocation.triggerType)}</span>
fe3b509122 {invocation.commitId && (
fe3b509123 <>
fe3b509124 <span>·</span>
fe3b509125 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
fe3b509126 </>
fe3b509127 )}
fe3b509128 <span>·</span>
fe3b509129 <span>
fe3b509130 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
fe3b509131 </span>
fe3b509132 <span className="ml-auto shrink-0">
fe3b509133 <TimeAgo date={newestRun.created_at} />
fe3b509134 </span>
fe3b509135 </div>
fe3b509136 </div>
fe3b509137 </Link>
fe3b509138 );
fe3b509139 })}
fe3b509140 </div>
da0f651141 </div>
da0f651142 );
da0f651143}