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