| 1 | "use client"; |
| 2 | |
| 3 | import Link from "next/link"; |
| 4 | import { useParams } from "next/navigation"; |
| 5 | import { useEffect, useState, useCallback, useRef } from "react"; |
| 6 | import { CanopyLogo } from "@/app/components/canopy-logo"; |
| 7 | import { canopy } from "@/lib/api"; |
| 8 | import { Badge } from "@/app/components/ui/badge"; |
| 9 | import { NavBar } from "@/app/components/ui/navbar"; |
| 10 | import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events"; |
| 11 | import { useAppSwitcherItems } from "@/lib/use-app-switcher"; |
| 12 | |
| 13 | const statusLabels: Record<string, string> = { |
| 14 | pending: "Pending", |
| 15 | running: "Running", |
| 16 | passed: "Passed", |
| 17 | failed: "Failed", |
| 18 | cancelled: "Cancelled", |
| 19 | }; |
| 20 | |
| 21 | const statusFaviconColor: Record<string, string> = { |
| 22 | pending: "#a09888", |
| 23 | running: "#6b4fa0", |
| 24 | passed: "#2d6b56", |
| 25 | failed: "#a05050", |
| 26 | cancelled: "#7a746c", |
| 27 | }; |
| 28 | |
| 29 | function 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 | |
| 34 | function 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 | |
| 50 | function 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 */ |
| 62 | function 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 | |
| 70 | export 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 | |