6.0 KB173 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { getCanopyRecentRuns } from "@/lib/grove-api";
4import { Badge } from "@/app/components/ui/badge";
5import { TimeAgo } from "@/app/components/time-ago";
6import {
7 formatTriggerType,
8 getInvocationRunSlug,
9 getInvocationStatus,
10 groupByInvocation,
11} from "@/lib/canopy-invocations";
12import { CanopyLiveRefresh } from "@/app/components/canopy-live-refresh";
13
14interface Props {
15 params: Promise<{ owner: string }>;
16}
17
18export async function generateMetadata({ params }: Props): Promise<Metadata> {
19 const { owner } = await params;
20 return { title: `${owner} · Canopy` };
21}
22
23interface Run {
24 id: number;
25 pipeline_name: string;
26 status: string;
27 trigger_type?: string | null;
28 commit_id: string | null;
29 commit_message: string | null;
30 trigger_ref?: string | null;
31 started_at?: string | null;
32 duration_ms?: number | null;
33 created_at: string;
34 repo_name: string;
35 owner_name: string;
36}
37
38const statusLabels: Record<string, string> = {
39 pending: "Pending",
40 running: "Running",
41 passed: "Passed",
42 failed: "Failed",
43 cancelled: "Cancelled",
44};
45
46async function getOwnerRuns(owner: string): Promise<Run[]> {
47 const data = await getCanopyRecentRuns<Run>({ owner, limit: 50 });
48 return data?.runs ?? [];
49}
50
51function groupByRepo(runs: Run[]) {
52 const groups = new Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }>();
53 const sorted = [...runs].sort((a, b) => b.id - a.id);
54 for (const run of sorted) {
55 const key = run.repo_name;
56 let group = groups.get(key);
57 if (!group) {
58 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
59 groups.set(key, group);
60 }
61 group.runs.push(run);
62 }
63 return Array.from(groups.values()).sort((a, b) => b.newestRunId - a.newestRunId);
64}
65
66export default async function CanopyOwnerPage({ params }: Props) {
67 const { owner } = await params;
68 const runs = await getOwnerRuns(owner);
69
70 if (runs.length === 0) {
71 return (
72 <div className="max-w-3xl mx-auto px-4 py-6">
73 <p
74 className="text-sm py-8 text-center"
75 style={{ color: "var(--text-faint)" }}
76 >
77 No builds yet for {owner}.
78 </p>
79 </div>
80 );
81 }
82
83 const repos = groupByRepo(runs);
84
85 return (
86 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
87 <CanopyLiveRefresh scope="global" />
88 {repos.map((group) => (
89 <div key={group.repo}>
90 <div className="pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
91 <Link
92 href={`/${group.owner}/${group.repo}`}
93 className="text-sm font-medium hover:underline"
94 style={{ color: "var(--text-primary)" }}
95 >
96 {group.repo}
97 </Link>
98 </div>
99 <div>
100 {(() => {
101 const invocations = groupByInvocation(group.runs);
102 return invocations.slice(0, 5).map((invocation) => {
103 const title =
104 invocation.commitMessage ||
105 (invocation.commitId
106 ? invocation.commitId.substring(0, 8)
107 : `${formatTriggerType(invocation.triggerType)} build`);
108
109 const summaryStatus = getInvocationStatus(invocation.runs);
110 const newestRun = invocation.runs[invocation.runs.length - 1];
111 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
112
113 return (
114 <Link
115 key={invocation.key}
116 href={invocationHref}
117 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
118 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
119 >
120 <span className="shrink-0 mt-0.5 sm:hidden">
121 <Badge variant={summaryStatus} compact>
122 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
123 </Badge>
124 </span>
125 <span className="shrink-0 mt-0.5 hidden sm:block">
126 <Badge
127 variant={summaryStatus}
128 style={{ minWidth: "4rem", textAlign: "center" }}
129 >
130 {statusLabels[summaryStatus] ?? summaryStatus}
131 </Badge>
132 </span>
133 <div className="min-w-0 flex-1">
134 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
135 {title}
136 </div>
137 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
138 <span>{formatTriggerType(invocation.triggerType)}</span>
139 {invocation.commitId && (
140 <>
141 <span>·</span>
142 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
143 </>
144 )}
145 <span>·</span>
146 <span>
147 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
148 </span>
149 <span className="ml-auto shrink-0">
150 <TimeAgo date={newestRun.created_at} />
151 </span>
152 </div>
153 </div>
154 </Link>
155 );
156 });
157 })()}
158 </div>
159 <div className="pt-1.5">
160 <Link
161 href={`/${group.owner}/${group.repo}`}
162 className="text-xs hover:underline"
163 style={{ color: "var(--text-muted)" }}
164 >
165 View all builds
166 </Link>
167 </div>
168 </div>
169 ))}
170 </div>
171 );
172}
173