web/app/canopy/%5Bowner%5D/page.tsxblame
View source
fe3b5091import type { Metadata } from "next";
fe3b5092import Link from "next/link";
fe3b5093import { 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";
fe3b50913
fe3b50914interface Props {
fe3b50915 params: Promise<{ owner: string }>;
fe3b50916}
fe3b50917
fe3b50918export async function generateMetadata({ params }: Props): Promise<Metadata> {
fe3b50919 const { owner } = await params;
fe3b50920 return { title: `${owner} · Canopy` };
fe3b50921}
fe3b50922
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 getOwnerRuns(owner: string): Promise<Run[]> {
fe3b50947 try {
fe3b50948 const res = await fetch(
fe3b50949 `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`,
fe3b50950 { cache: "no-store" },
fe3b50951 );
fe3b50952 if (!res.ok) return [];
fe3b50953 const data = await res.json();
fe3b50954 return data.runs ?? [];
fe3b50955 } catch {
fe3b50956 return [];
fe3b50957 }
fe3b50958}
fe3b50959
fe3b50960function groupByRepo(runs: Run[]) {
fe3b50961 const groups = new Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }>();
fe3b50962 const sorted = [...runs].sort((a, b) => b.id - a.id);
fe3b50963 for (const run of sorted) {
fe3b50964 const key = run.repo_name;
fe3b50965 let group = groups.get(key);
fe3b50966 if (!group) {
fe3b50967 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
fe3b50968 groups.set(key, group);
fe3b50969 }
fe3b50970 group.runs.push(run);
fe3b50971 }
fe3b50972 return Array.from(groups.values()).sort((a, b) => b.newestRunId - a.newestRunId);
fe3b50973}
fe3b50974
fe3b50975export default async function CanopyOwnerPage({ params }: Props) {
fe3b50976 const { owner } = await params;
fe3b50977 const runs = await getOwnerRuns(owner);
fe3b50978
fe3b50979 if (runs.length === 0) {
fe3b50980 return (
fe3b50981 <div className="max-w-3xl mx-auto px-4 py-6">
fe3b50982 <p
fe3b50983 className="text-sm py-8 text-center"
fe3b50984 style={{ color: "var(--text-faint)" }}
fe3b50985 >
fe3b50986 No builds yet for {owner}.
fe3b50987 </p>
fe3b50988 </div>
fe3b50989 );
fe3b50990 }
fe3b50991
fe3b50992 const repos = groupByRepo(runs);
fe3b50993
fe3b50994 return (
fe3b50995 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
5bcd5db96 <CanopyLiveRefresh scope="global" />
fe3b50997 {repos.map((group) => (
fe3b50998 <div key={group.repo}>
fe3b50999 <div className="pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
fe3b509100 <Link
fe3b509101 href={`/${group.owner}/${group.repo}`}
fe3b509102 className="text-sm font-medium hover:underline"
fe3b509103 style={{ color: "var(--text-primary)" }}
fe3b509104 >
fe3b509105 {group.repo}
fe3b509106 </Link>
fe3b509107 </div>
fe3b509108 <div>
fe3b509109 {(() => {
fe3b509110 const invocations = groupByInvocation(group.runs);
fe3b509111 return invocations.slice(0, 5).map((invocation) => {
fe3b509112 const title =
fe3b509113 invocation.commitMessage ||
fe3b509114 (invocation.commitId
fe3b509115 ? invocation.commitId.substring(0, 8)
fe3b509116 : `${formatTriggerType(invocation.triggerType)} build`);
fe3b509117
fe3b509118 const summaryStatus = getInvocationStatus(invocation.runs);
fe3b509119 const newestRun = invocation.runs[invocation.runs.length - 1];
fe3b509120 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
fe3b509121
fe3b509122 return (
fe3b509123 <Link
fe3b509124 key={invocation.key}
fe3b509125 href={invocationHref}
fe3b509126 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
fe3b509127 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
fe3b509128 >
fe3b509129 <span className="shrink-0 mt-0.5 sm:hidden">
fe3b509130 <Badge variant={summaryStatus} compact>
fe3b509131 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
fe3b509132 </Badge>
fe3b509133 </span>
fe3b509134 <span className="shrink-0 mt-0.5 hidden sm:block">
fe3b509135 <Badge
fe3b509136 variant={summaryStatus}
fe3b509137 style={{ minWidth: "4rem", textAlign: "center" }}
fe3b509138 >
fe3b509139 {statusLabels[summaryStatus] ?? summaryStatus}
fe3b509140 </Badge>
fe3b509141 </span>
fe3b509142 <div className="min-w-0 flex-1">
fe3b509143 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
fe3b509144 {title}
fe3b509145 </div>
fe3b509146 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
fe3b509147 <span>{formatTriggerType(invocation.triggerType)}</span>
fe3b509148 {invocation.commitId && (
fe3b509149 <>
fe3b509150 <span>·</span>
fe3b509151 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
fe3b509152 </>
fe3b509153 )}
fe3b509154 <span>·</span>
fe3b509155 <span>
fe3b509156 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
fe3b509157 </span>
fe3b509158 <span className="ml-auto shrink-0">
fe3b509159 <TimeAgo date={newestRun.created_at} />
fe3b509160 </span>
fe3b509161 </div>
fe3b509162 </div>
fe3b509163 </Link>
fe3b509164 );
fe3b509165 });
fe3b509166 })()}
fe3b509167 </div>
fe3b509168 <div className="pt-1.5">
fe3b509169 <Link
fe3b509170 href={`/${group.owner}/${group.repo}`}
fe3b509171 className="text-xs hover:underline"
fe3b509172 style={{ color: "var(--text-muted)" }}
fe3b509173 >
fe3b509174 View all builds
fe3b509175 </Link>
fe3b509176 </div>
fe3b509177 </div>
fe3b509178 ))}
fe3b509179 </div>
fe3b509180 );
fe3b509181}