6.1 KB182 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { groveApiUrl } from "@/lib/utils";
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 try {
48 const res = await fetch(
49 `${groveApiUrl}/api/canopy/recent-runs?owner=${encodeURIComponent(owner)}&limit=50`,
50 { cache: "no-store" },
51 );
52 if (!res.ok) return [];
53 const data = await res.json();
54 return data.runs ?? [];
55 } catch {
56 return [];
57 }
58}
59
60function groupByRepo(runs: Run[]) {
61 const groups = new Map<string, { owner: string; repo: string; runs: Run[]; newestRunId: number }>();
62 const sorted = [...runs].sort((a, b) => b.id - a.id);
63 for (const run of sorted) {
64 const key = run.repo_name;
65 let group = groups.get(key);
66 if (!group) {
67 group = { owner: run.owner_name, repo: run.repo_name, runs: [], newestRunId: run.id };
68 groups.set(key, group);
69 }
70 group.runs.push(run);
71 }
72 return Array.from(groups.values()).sort((a, b) => b.newestRunId - a.newestRunId);
73}
74
75export default async function CanopyOwnerPage({ params }: Props) {
76 const { owner } = await params;
77 const runs = await getOwnerRuns(owner);
78
79 if (runs.length === 0) {
80 return (
81 <div className="max-w-3xl mx-auto px-4 py-6">
82 <p
83 className="text-sm py-8 text-center"
84 style={{ color: "var(--text-faint)" }}
85 >
86 No builds yet for {owner}.
87 </p>
88 </div>
89 );
90 }
91
92 const repos = groupByRepo(runs);
93
94 return (
95 <div className="max-w-3xl mx-auto px-4 py-6 flex flex-col gap-6">
96 <CanopyLiveRefresh scope="global" />
97 {repos.map((group) => (
98 <div key={group.repo}>
99 <div className="pb-2" style={{ borderBottom: "1px solid var(--divide)" }}>
100 <Link
101 href={`/${group.owner}/${group.repo}`}
102 className="text-sm font-medium hover:underline"
103 style={{ color: "var(--text-primary)" }}
104 >
105 {group.repo}
106 </Link>
107 </div>
108 <div>
109 {(() => {
110 const invocations = groupByInvocation(group.runs);
111 return invocations.slice(0, 5).map((invocation) => {
112 const title =
113 invocation.commitMessage ||
114 (invocation.commitId
115 ? invocation.commitId.substring(0, 8)
116 : `${formatTriggerType(invocation.triggerType)} build`);
117
118 const summaryStatus = getInvocationStatus(invocation.runs);
119 const newestRun = invocation.runs[invocation.runs.length - 1];
120 const invocationHref = `/${group.owner}/${group.repo}/runs/${getInvocationRunSlug(invocation, invocations)}`;
121
122 return (
123 <Link
124 key={invocation.key}
125 href={invocationHref}
126 className="flex items-start gap-2 sm:gap-3 py-2.5 px-1 hover-row"
127 style={{ borderBottom: "1px solid var(--divide)", textDecoration: "none", color: "inherit" }}
128 >
129 <span className="shrink-0 mt-0.5 sm:hidden">
130 <Badge variant={summaryStatus} compact>
131 {(statusLabels[summaryStatus] ?? summaryStatus).charAt(0)}
132 </Badge>
133 </span>
134 <span className="shrink-0 mt-0.5 hidden sm:block">
135 <Badge
136 variant={summaryStatus}
137 style={{ minWidth: "4rem", textAlign: "center" }}
138 >
139 {statusLabels[summaryStatus] ?? summaryStatus}
140 </Badge>
141 </span>
142 <div className="min-w-0 flex-1">
143 <div className="text-sm truncate" style={{ color: "var(--text-primary)" }}>
144 {title}
145 </div>
146 <div className="flex items-center gap-2 mt-0.5 text-xs" style={{ color: "var(--text-faint)" }}>
147 <span>{formatTriggerType(invocation.triggerType)}</span>
148 {invocation.commitId && (
149 <>
150 <span>·</span>
151 <span className="font-mono">{invocation.commitId.substring(0, 8)}</span>
152 </>
153 )}
154 <span>·</span>
155 <span>
156 {invocation.runs.length} workflow{invocation.runs.length === 1 ? "" : "s"}
157 </span>
158 <span className="ml-auto shrink-0">
159 <TimeAgo date={newestRun.created_at} />
160 </span>
161 </div>
162 </div>
163 </Link>
164 );
165 });
166 })()}
167 </div>
168 <div className="pt-1.5">
169 <Link
170 href={`/${group.owner}/${group.repo}`}
171 className="text-xs hover:underline"
172 style={{ color: "var(--text-muted)" }}
173 >
174 View all builds
175 </Link>
176 </div>
177 </div>
178 ))}
179 </div>
180 );
181}
182