8.1 KB253 lines
Blame
1"use client";
2
3import Link from "next/link";
4import { useParams } from "next/navigation";
5import { useEffect, useState, useCallback, useRef } from "react";
6import { CanopyLogo } from "@/app/components/canopy-logo";
7import { canopy } from "@/lib/api";
8import { Badge } from "@/app/components/ui/badge";
9import { NavBar } from "@/app/components/ui/navbar";
10import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events";
11import { useAppSwitcherItems } from "@/lib/use-app-switcher";
12
13const statusLabels: Record<string, string> = {
14 pending: "Pending",
15 running: "Running",
16 passed: "Passed",
17 failed: "Failed",
18 cancelled: "Cancelled",
19};
20
21const statusFaviconColor: Record<string, string> = {
22 pending: "#a09888",
23 running: "#6b4fa0",
24 passed: "#2d6b56",
25 failed: "#a05050",
26 cancelled: "#7a746c",
27};
28
29function faviconSvgUrl(fill: string): string {
30 const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"><circle cx="32" cy="32" r="32" fill="${fill}"/><path d="M31 42 L30 53" stroke="white" stroke-width="2.5" stroke-linecap="round"/><path d="M30.5 46 L34 49.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/><path d="M31 42C18 40 11 32 14 24C16 17 24 12 31 14C33 10 40 12 43 16C48 14 52 22 49 28C52 34 46 40 38 40C36 42 33 43 31 42Z" fill="white"/></svg>`;
31 return `data:image/svg+xml,${encodeURIComponent(svg)}`;
32}
33
34function setFavicon(color: string) {
35 const url = faviconSvgUrl(color);
36 const icons = document.querySelectorAll<HTMLLinkElement>("link[rel~='icon']");
37 if (icons.length === 0) {
38 const link = document.createElement("link");
39 link.rel = "icon";
40 link.type = "image/svg+xml";
41 link.href = url;
42 document.head.appendChild(link);
43 } else {
44 for (const link of icons) {
45 link.href = url;
46 }
47 }
48}
49
50function useStatusFavicon(status: string | null, loading: boolean) {
51 useEffect(() => {
52 const color = status
53 ? statusFaviconColor[status]
54 : loading
55 ? "#7a746c"
56 : null;
57 if (color) setFavicon(color);
58 }, [status, loading]);
59}
60
61/** Derive aggregate status from per-repo latest statuses */
62function deriveAggregateStatus(statuses: string[]): string | null {
63 if (statuses.length === 0) return null;
64 if (statuses.includes("running")) return "running";
65 if (statuses.includes("failed")) return "failed";
66 if (statuses.includes("pending")) return "pending";
67 return statuses[0] ?? null;
68}
69
70export function CanopyNav() {
71 const params = useParams<{ owner?: string; repo?: string; runId?: string }>();
72 const owner = params?.owner;
73 const repo = params?.repo;
74 const runId = params?.runId;
75 const [runStatus, setRunStatus] = useState<string | null>(null);
76 const [runStatusLoading, setRunStatusLoading] = useState(false);
77 const [repoStatus, setRepoStatus] = useState<string | null>(null);
78 const [repoStatusLoaded, setRepoStatusLoaded] = useState(false);
79
80 // Fetch initial status for a specific run detail page
81 const numericRunId = runId ? Number.parseInt(runId, 10) : NaN;
82 useEffect(() => {
83 if (!owner || !repo || !runId || Number.isNaN(numericRunId)) {
84 setRunStatus(null);
85 setRunStatusLoading(false);
86 return;
87 }
88 let active = true;
89 setRunStatusLoading(true);
90 setRunStatus(null);
91 canopy.getRun(owner, repo, numericRunId).then((data) => {
92 if (active) {
93 setRunStatus(data.run.status);
94 setRunStatusLoading(false);
95 }
96 }).catch(() => {
97 if (active) {
98 setRunStatus(null);
99 setRunStatusLoading(false);
100 }
101 });
102 return () => { active = false; };
103 }, [owner, repo, runId, numericRunId]);
104
105 // Live updates for run status via SSE
106 useCanopyEvents({
107 scope: "run",
108 runId: Number.isNaN(numericRunId) ? undefined : numericRunId,
109 enabled: !!runId && !Number.isNaN(numericRunId),
110 onEvent: useCallback((event: CanopyEvent) => {
111 if (event.type === "run:started" || event.type === "run:completed" || event.type === "run:cancelled") {
112 setRunStatus(event.status ?? null);
113 setRunStatusLoading(false);
114 }
115 }, []),
116 });
117
118 // Fetch aggregate status for repo-level pages or canopy homepage
119 const repoFetchActive = useRef(false);
120 const repoFetchQueued = useRef(false);
121
122 const fetchRepoStatus = useCallback(() => {
123 if (repoFetchActive.current) {
124 repoFetchQueued.current = true;
125 return;
126 }
127 repoFetchActive.current = true;
128
129 const promise = owner && repo
130 ? canopy.listRuns(owner, repo, { limit: 10 }).then((d) => d.runs)
131 : canopy.recentRuns({ limit: 20, owner: owner ?? undefined }).then((d) => d.runs).catch(() => null);
132
133 promise
134 .then((runs) => {
135 if (!runs) {
136 setRepoStatus(null);
137 setRepoStatusLoaded(true);
138 return;
139 }
140 const nonCancelled = runs.filter((r) => r.status !== "cancelled");
141 let statuses: string[];
142 if (owner && repo) {
143 statuses = nonCancelled.length > 0 ? [nonCancelled[0].status] : [];
144 } else {
145 const latestPerRepo = new Map<string, string>();
146 for (const r of nonCancelled) {
147 const key = "owner_name" in r ? `${(r as any).owner_name}/${(r as any).repo_name}` : "default";
148 if (!latestPerRepo.has(key)) latestPerRepo.set(key, r.status);
149 }
150 statuses = Array.from(latestPerRepo.values());
151 }
152 setRepoStatus(deriveAggregateStatus(statuses));
153 setRepoStatusLoaded(true);
154 })
155 .catch(() => {
156 setRepoStatus(null);
157 setRepoStatusLoaded(true);
158 })
159 .finally(() => {
160 repoFetchActive.current = false;
161 if (repoFetchQueued.current) {
162 repoFetchQueued.current = false;
163 fetchRepoStatus();
164 }
165 });
166 }, [owner, repo]);
167
168 useEffect(() => {
169 if (runId) {
170 setRepoStatus(null);
171 setRepoStatusLoaded(false);
172 return;
173 }
174 fetchRepoStatus();
175 }, [runId, fetchRepoStatus]);
176
177 // SSE for repo/global status
178 useCanopyEvents({
179 scope: owner && repo ? "repo" : "global",
180 owner: owner ?? undefined,
181 repo: repo ?? undefined,
182 enabled: !runId,
183 onEvent: useCallback((event: CanopyEvent) => {
184 if (event.type === "log:append") return;
185 fetchRepoStatus();
186 }, [fetchRepoStatus]),
187 });
188
189 const effectiveStatus = runId ? runStatus : repoStatus;
190 const isStatusPending = runId ? runStatusLoading : !repoStatusLoaded;
191 useStatusFavicon(effectiveStatus, !!isStatusPending);
192
193 const appSwitcherItems = useAppSwitcherItems("canopy", { owner, repo });
194
195 return (
196 <NavBar
197 logo={<CanopyLogo size={28} />}
198 productName="Canopy"
199 showProductName={!owner}
200 appSwitcherItems={appSwitcherItems}
201 breadcrumbs={
202 <>
203 {owner && (
204 <>
205 <span style={{ color: "var(--text-faint)" }}>/</span>
206 <Link
207 href={`/${owner}`}
208 className="hover:underline truncate"
209 style={{ color: "var(--text-muted)" }}
210 >
211 {owner}
212 </Link>
213 </>
214 )}
215 {owner && repo && (
216 <>
217 <span style={{ color: "var(--text-faint)" }}>/</span>
218 <Link
219 href={`/${owner}/${repo}`}
220 className="hover:underline truncate"
221 style={{ color: runId ? "var(--text-muted)" : "var(--text-primary)" }}
222 >
223 {repo}
224 </Link>
225 </>
226 )}
227 {runId && (
228 <>
229 <span style={{ color: "var(--text-faint)" }}>/</span>
230 <span style={{ color: "var(--text-primary)" }}>#{runId}</span>
231 {runStatus && (
232 <Badge variant={runStatus}>
233 {statusLabels[runStatus] ?? runStatus}
234 </Badge>
235 )}
236 {!runStatus && runStatusLoading && (
237 <span
238 className="skeleton"
239 style={{
240 width: "4.6rem",
241 height: "1.15rem",
242 borderRadius: "9999px",
243 }}
244 />
245 )}
246 </>
247 )}
248 </>
249 }
250 />
251 );
252}
253