web/app/canopy/canopy-nav.tsxblame
View source
da0f6511"use client";
da0f6512
da0f6513import Link from "next/link";
da0f6514import { useParams } from "next/navigation";
59a80f95import { useEffect, useState, useCallback, useRef } from "react";
da0f6516import { CanopyLogo } from "@/app/components/canopy-logo";
087adca7import { canopy } from "@/lib/api";
087adca8import { Badge } from "@/app/components/ui/badge";
0b4b5829import { NavBar } from "@/app/components/ui/navbar";
5bcd5db10import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events";
10621c511import { useAppSwitcherItems } from "@/lib/use-app-switcher";
087adca12
087adca13const statusLabels: Record<string, string> = {
087adca14 pending: "Pending",
087adca15 running: "Running",
087adca16 passed: "Passed",
087adca17 failed: "Failed",
087adca18 cancelled: "Cancelled",
087adca19};
da0f65120
d1cff7021const statusFaviconColor: Record<string, string> = {
d1cff7022 pending: "#a09888",
d1cff7023 running: "#6b4fa0",
d1cff7024 passed: "#2d6b56",
d1cff7025 failed: "#a05050",
d1cff7026 cancelled: "#7a746c",
d1cff7027};
d1cff7028
d1cff7029function faviconSvgUrl(fill: string): string {
d1cff7030 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>`;
d1cff7031 return `data:image/svg+xml,${encodeURIComponent(svg)}`;
d1cff7032}
d1cff7033
59a80f934function setFavicon(color: string) {
59a80f935 const url = faviconSvgUrl(color);
59a80f936 const icons = document.querySelectorAll<HTMLLinkElement>("link[rel~='icon']");
59a80f937 if (icons.length === 0) {
59a80f938 const link = document.createElement("link");
59a80f939 link.rel = "icon";
59a80f940 link.type = "image/svg+xml";
59a80f941 link.href = url;
59a80f942 document.head.appendChild(link);
59a80f943 } else {
59a80f944 for (const link of icons) {
59a80f945 link.href = url;
59a80f946 }
59a80f947 }
59a80f948}
59a80f949
d1cff7050function useStatusFavicon(status: string | null, loading: boolean) {
d1cff7051 useEffect(() => {
d1cff7052 const color = status
d1cff7053 ? statusFaviconColor[status]
d1cff7054 : loading
d1cff7055 ? "#7a746c"
b2bd12356 : null;
59a80f957 if (color) setFavicon(color);
d1cff7058 }, [status, loading]);
d1cff7059}
d1cff7060
fe3b50961/** Derive aggregate status from per-repo latest statuses */
d1cff7062function deriveAggregateStatus(statuses: string[]): string | null {
d1cff7063 if (statuses.length === 0) return null;
fe3b50964 if (statuses.includes("running")) return "running";
fe3b50965 if (statuses.includes("failed")) return "failed";
fe3b50966 if (statuses.includes("pending")) return "pending";
d1cff7067 return statuses[0] ?? null;
d1cff7068}
d1cff7069
da0f65170export function CanopyNav() {
da0f65171 const params = useParams<{ owner?: string; repo?: string; runId?: string }>();
da0f65172 const owner = params?.owner;
da0f65173 const repo = params?.repo;
da0f65174 const runId = params?.runId;
087adca75 const [runStatus, setRunStatus] = useState<string | null>(null);
087adca76 const [runStatusLoading, setRunStatusLoading] = useState(false);
d1cff7077 const [repoStatus, setRepoStatus] = useState<string | null>(null);
d1cff7078 const [repoStatusLoaded, setRepoStatusLoaded] = useState(false);
087adca79
5bcd5db80 // Fetch initial status for a specific run detail page
5bcd5db81 const numericRunId = runId ? Number.parseInt(runId, 10) : NaN;
087adca82 useEffect(() => {
5bcd5db83 if (!owner || !repo || !runId || Number.isNaN(numericRunId)) {
087adca84 setRunStatus(null);
087adca85 setRunStatusLoading(false);
087adca86 return;
087adca87 }
087adca88 let active = true;
73fdc9e89 setRunStatusLoading(true);
73fdc9e90 setRunStatus(null);
5bcd5db91 canopy.getRun(owner, repo, numericRunId).then((data) => {
5bcd5db92 if (active) {
5bcd5db93 setRunStatus(data.run.status);
5bcd5db94 setRunStatusLoading(false);
5bcd5db95 }
5bcd5db96 }).catch(() => {
5bcd5db97 if (active) {
5bcd5db98 setRunStatus(null);
5bcd5db99 setRunStatusLoading(false);
5bcd5db100 }
5bcd5db101 });
5bcd5db102 return () => { active = false; };
5bcd5db103 }, [owner, repo, runId, numericRunId]);
73fdc9e104
5bcd5db105 // Live updates for run status via SSE
5bcd5db106 useCanopyEvents({
5bcd5db107 scope: "run",
5bcd5db108 runId: Number.isNaN(numericRunId) ? undefined : numericRunId,
5bcd5db109 enabled: !!runId && !Number.isNaN(numericRunId),
5bcd5db110 onEvent: useCallback((event: CanopyEvent) => {
5bcd5db111 if (event.type === "run:started" || event.type === "run:completed" || event.type === "run:cancelled") {
5bcd5db112 setRunStatus(event.status ?? null);
5bcd5db113 setRunStatusLoading(false);
5bcd5db114 }
5bcd5db115 }, []),
5bcd5db116 });
da0f651117
0b4b582118 // Fetch aggregate status for repo-level pages or canopy homepage
59a80f9119 const repoFetchActive = useRef(false);
59a80f9120 const repoFetchQueued = useRef(false);
59a80f9121
59a80f9122 const fetchRepoStatus = useCallback(() => {
59a80f9123 if (repoFetchActive.current) {
59a80f9124 repoFetchQueued.current = true;
d1cff70125 return;
d1cff70126 }
59a80f9127 repoFetchActive.current = true;
d1cff70128
d1cff70129 const promise = owner && repo
d1cff70130 ? canopy.listRuns(owner, repo, { limit: 10 }).then((d) => d.runs)
f4e5cf1131 : canopy.recentRuns({ limit: 20, owner: owner ?? undefined }).then((d) => d.runs).catch(() => null);
d1cff70132
d1cff70133 promise
d1cff70134 .then((runs) => {
59a80f9135 if (!runs) {
59a80f9136 setRepoStatus(null);
59a80f9137 setRepoStatusLoaded(true);
d1cff70138 return;
d1cff70139 }
fe3b509140 const nonCancelled = runs.filter((r) => r.status !== "cancelled");
fe3b509141 let statuses: string[];
fe3b509142 if (owner && repo) {
fe3b509143 statuses = nonCancelled.length > 0 ? [nonCancelled[0].status] : [];
fe3b509144 } else {
fe3b509145 const latestPerRepo = new Map<string, string>();
fe3b509146 for (const r of nonCancelled) {
fe3b509147 const key = "owner_name" in r ? `${(r as any).owner_name}/${(r as any).repo_name}` : "default";
fe3b509148 if (!latestPerRepo.has(key)) latestPerRepo.set(key, r.status);
fe3b509149 }
fe3b509150 statuses = Array.from(latestPerRepo.values());
fe3b509151 }
d1cff70152 setRepoStatus(deriveAggregateStatus(statuses));
d1cff70153 setRepoStatusLoaded(true);
d1cff70154 })
d1cff70155 .catch(() => {
59a80f9156 setRepoStatus(null);
59a80f9157 setRepoStatusLoaded(true);
59a80f9158 })
59a80f9159 .finally(() => {
59a80f9160 repoFetchActive.current = false;
59a80f9161 if (repoFetchQueued.current) {
59a80f9162 repoFetchQueued.current = false;
59a80f9163 fetchRepoStatus();
d1cff70164 }
d1cff70165 });
59a80f9166 }, [owner, repo]);
5bcd5db167
59a80f9168 useEffect(() => {
59a80f9169 if (runId) {
59a80f9170 setRepoStatus(null);
59a80f9171 setRepoStatusLoaded(false);
59a80f9172 return;
59a80f9173 }
59a80f9174 fetchRepoStatus();
59a80f9175 }, [runId, fetchRepoStatus]);
59a80f9176
0b4b582177 // SSE for repo/global status
5bcd5db178 useCanopyEvents({
5bcd5db179 scope: owner && repo ? "repo" : "global",
5bcd5db180 owner: owner ?? undefined,
5bcd5db181 repo: repo ?? undefined,
5bcd5db182 enabled: !runId,
59a80f9183 onEvent: useCallback((event: CanopyEvent) => {
59a80f9184 if (event.type === "log:append") return;
59a80f9185 fetchRepoStatus();
59a80f9186 }, [fetchRepoStatus]),
5bcd5db187 });
d1cff70188
d1cff70189 const effectiveStatus = runId ? runStatus : repoStatus;
d1cff70190 const isStatusPending = runId ? runStatusLoading : !repoStatusLoaded;
d1cff70191 useStatusFavicon(effectiveStatus, !!isStatusPending);
d1cff70192
10621c5193 const appSwitcherItems = useAppSwitcherItems("canopy", { owner, repo });
10621c5194
da0f651195 return (
0b4b582196 <NavBar
0b4b582197 logo={<CanopyLogo size={28} />}
0b4b582198 productName="Canopy"
0b4b582199 showProductName={!owner}
10621c5200 appSwitcherItems={appSwitcherItems}
0b4b582201 breadcrumbs={
0b4b582202 <>
da0f651203 {owner && (
da0f651204 <>
da0f651205 <span style={{ color: "var(--text-faint)" }}>/</span>
da0f651206 <Link
fe3b509207 href={`/${owner}`}
da0f651208 className="hover:underline truncate"
da0f651209 style={{ color: "var(--text-muted)" }}
da0f651210 >
da0f651211 {owner}
da0f651212 </Link>
da0f651213 </>
da0f651214 )}
da0f651215 {owner && repo && (
da0f651216 <>
da0f651217 <span style={{ color: "var(--text-faint)" }}>/</span>
da0f651218 <Link
da0f651219 href={`/${owner}/${repo}`}
da0f651220 className="hover:underline truncate"
da0f651221 style={{ color: runId ? "var(--text-muted)" : "var(--text-primary)" }}
da0f651222 >
da0f651223 {repo}
da0f651224 </Link>
da0f651225 </>
da0f651226 )}
da0f651227 {runId && (
da0f651228 <>
da0f651229 <span style={{ color: "var(--text-faint)" }}>/</span>
da0f651230 <span style={{ color: "var(--text-primary)" }}>#{runId}</span>
087adca231 {runStatus && (
087adca232 <Badge variant={runStatus}>
087adca233 {statusLabels[runStatus] ?? runStatus}
087adca234 </Badge>
087adca235 )}
087adca236 {!runStatus && runStatusLoading && (
087adca237 <span
087adca238 className="skeleton"
087adca239 style={{
087adca240 width: "4.6rem",
087adca241 height: "1.15rem",
087adca242 borderRadius: "9999px",
087adca243 }}
087adca244 />
087adca245 )}
da0f651246 </>
da0f651247 )}
0b4b582248 </>
0b4b582249 }
0b4b582250 />
da0f651251 );
da0f651252}