| 1 | import { |
| 2 | Activity, |
| 3 | ArrowRight, |
| 4 | ArrowUpRight, |
| 5 | Code2, |
| 6 | GitBranch, |
| 7 | GitCommitHorizontal, |
| 8 | Hand, |
| 9 | HeartPulse, |
| 10 | Lock, |
| 11 | Package as PackageGlyph, |
| 12 | Radio, |
| 13 | Rocket, |
| 14 | RotateCw, |
| 15 | RotateCcw, |
| 16 | Settings2, |
| 17 | } from "lucide-react"; |
| 18 | import { type ReactNode, Suspense } from "react"; |
| 19 | import { Await, Form, Link } from "react-router"; |
| 20 | |
| 21 | import { type ErrorGroup, type PackageSummary, type Project, type TelemetryOverview, workOwner } from "@g1t/contracts"; |
| 22 | |
| 23 | import type { Route } from "./+types/overview"; |
| 24 | import { DeployLink, host, StatusDot } from "../../components/deploy"; |
| 25 | import { useRefreshWhile } from "../../lib/refresh"; |
| 26 | import { DeployStrip, NeedsList, Panel, Quiet, Unavailable, percent as share } from "../../components/mission"; |
| 27 | import { PackageIcon } from "../../components/package-icon"; |
| 28 | import { Hint } from "../../components/ui/hint"; |
| 29 | import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton"; |
| 30 | import { ProductionShot } from "../../components/production-shot"; |
| 31 | import { GithubLinkStrip } from "../../components/github"; |
| 32 | import { githubApp } from "../../lib/github.server"; |
| 33 | import { ButtonLink, CopyLine, SoonPill, SubmitButton, TimeAgo } from "../../components/ui"; |
| 34 | import { Card } from "../../components/ui/card"; |
| 35 | import { Button } from "../../components/ui/button"; |
| 36 | import { type LivePull, LiveNowList, StartHere } from "../../components/coordination"; |
| 37 | import { awakeAndIdle, liveByItem } from "../../lib/coordination"; |
| 38 | import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission"; |
| 39 | import { repoWorkPath, sessionsPath } from "../../lib/work-items"; |
| 40 | import { count, duration, percent } from "../../lib/observability"; |
| 41 | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; |
| 42 | import { |
| 43 | PUBLISH_GUIDES, |
| 44 | kindLabel, |
| 45 | latestTag, |
| 46 | libraryPackages, |
| 47 | packageName, |
| 48 | packagePath, |
| 49 | projectChanges, |
| 50 | projectPlan, |
| 51 | publishGuide, |
| 52 | } from "../../lib/project-kind"; |
| 53 | import { KindMenu } from "../../components/project-about"; |
| 54 | import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns, deploymentStatus } from "../../components/project-head"; |
| 55 | import { actions, deployments, packages, projects, repos, telemetry, work } from "../../lib/services.server"; |
| 56 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; |
| 57 | import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server"; |
| 58 | import { shotVersion } from "./production-screenshot"; |
| 59 | import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel"; |
| 60 | import { STATE_WORD, nativeProduction, productionEnvironment, reportedProduction } from "../../lib/deployments"; |
| 61 | import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image"; |
| 62 | import { useAddresses } from "../../lib/addresses"; |
| 63 | import { commitChecksFor } from "../../lib/commit-checks.server"; |
| 64 | import { CommitChecksSummary } from "../../components/commit-checks"; |
| 65 | import { projectCheckSections, hasSeparateAttempt } from "../../lib/project-checks"; |
| 66 | import { StatusIcon, Duration, shortRef, standingWord } from "../../components/actions"; |
| 67 | import { resolveRange, rangeLabel } from "../../lib/usage"; |
| 68 | import { deliveryDays, duration as deliveryDuration } from "../../lib/workspace-insights"; |
| 69 | import { repoPages } from "../../lib/repo-live"; |
| 70 | import { useRepoLive } from "../../lib/use-repo-live"; |
| 71 | |
| 72 | /** A library's packages shown on its overview; the rest are a link away. */ |
| 73 | const PACKAGES_SHOWN = 3; |
| 74 | /** Work items being worked on that Live now lists; Sessions has the rest. */ |
| 75 | const LIVE_SHOWN = 4; |
| 76 | /** Open errors the Observability card names. */ |
| 77 | const ERRORS_SHOWN = 3; |
| 78 | const DAY_MS = 86_400_000; |
| 79 | |
| 80 | /** |
| 81 | * A project's overview: what it is and whether it is healthy (production, |
| 82 | * its traffic and errors, its checks), and who is working on it right now. |
| 83 | * The repository itself (files, branches, commits) is Code's; pull |
| 84 | * requests and the merge queue are Work's and Pull requests'. This page |
| 85 | * links to them rather than repeating them. |
| 86 | * |
| 87 | * It streams: the layout's header and tabs go out at once with a skeleton |
| 88 | * below them, and everything here follows in the same response as it |
| 89 | * settles. Crawlers wait for all of it (entry.server.tsx). |
| 90 | */ |
| 91 | export function loader({ params, context }: Route.LoaderArgs) { |
| 92 | return { overview: overviewData({ params, context }) }; |
| 93 | } |
| 94 | |
| 95 | async function overviewData({ params, context }: Pick<Route.LoaderArgs, "params" | "context">) { |
| 96 | const viewer = getViewer(context); |
| 97 | const path = { namespace: params.owner, name: params.repo }; |
| 98 | const ref = { workspace: params.owner, slug: params.repo }; |
| 99 | const now = Date.now(); |
| 100 | // A section whose service fails shows its own empty state; the page stays up. |
| 101 | const soft = <T,>(promise: Promise<T> | null): Promise<T | null> => |
| 102 | promise ? promise.catch((error) => (console.warn("overview:", error), null)) : Promise.resolve(null); |
| 103 | // People with a role of their own here see its running parts: its |
| 104 | // builds, previews and observability; everyone who can read it sees the |
| 105 | // rest. Not awaited: only those sections wait on the repository lookup, |
| 106 | // which the layout makes in this request too. |
| 107 | const accessP = accessTo(context, params); |
| 108 | const memberP = accessP.then((access) => access.insider); |
| 109 | const forMembers = <T,>(start: () => Promise<T>): Promise<T | null> => memberP.then((member) => (member ? soft(start()) : null)); |
| 110 | const repoP = soft(repoFor(context, params)); |
| 111 | const projectP = soft(projectFor(context, params)); |
| 112 | // Deployments from anywhere (g1t.page, g1t Actions, the API), by environment, for anyone who can read it. |
| 113 | const environmentsP = projectP.then((found) => |
| 114 | soft(deployments.environments(found?.ok && found.value.source.kind === "hosted" ? found.value.source.repo : path, viewer)), |
| 115 | ); |
| 116 | const planP = projectP.then((project) => (project?.ok ? projectPlan(project.value) : null)); |
| 117 | // A library or a tool shows its packages where an app shows production. |
| 118 | const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null)); |
| 119 | const packagesP = libraryRepoP.then((repo) => (repo ? soft(packages.list(params.owner, viewer, { repoId: repo.id })) : null)); |
| 120 | const workflowRunsP = soft(actions.runs(path, viewer, { limit: 30 })); |
| 121 | // What waits on someone signed in: their pull requests' lifecycles, and the open ones. |
| 122 | const minePullsP = repoP.then((repo) => |
| 123 | repo?.ok && viewer ? soft(work.listActivePulls(viewer)).then((list) => (list ?? []).filter((item) => item.pull.repoId === repo.value.id)) : null, |
| 124 | ); |
| 125 | const openP = viewer ? soft(work.listPulls(path, viewer, "open")) : Promise.resolve(null); |
| 126 | // Where its code came from on GitHub, for members. |
| 127 | const githubP = repoP.then((repo) => (repo?.ok ? forMembers(() => githubApp.link(repo.value.id)) : null)); |
| 128 | const listP = forMembers(() => deployments.list(ref, viewer)); |
| 129 | // Its running software's last day, for members, on an installation with Observability. |
| 130 | const service = telemetry; |
| 131 | const telemetryP = service |
| 132 | ? projectP.then((found) => |
| 133 | found?.ok |
| 134 | ? forMembers(async () => { |
| 135 | const project_id = found.value.id; |
| 136 | const [overview, errors] = await Promise.all([ |
| 137 | soft(service.overview({ project_id, workspace: params.owner, from_us: (now - DAY_MS) * 1000, to_us: now * 1000, step_s: 3600 })), |
| 138 | soft(service.errors({ project_id, workspace: params.owner, state: "open" })), |
| 139 | ]); |
| 140 | return { overview, errors }; |
| 141 | }) |
| 142 | : null, |
| 143 | ) |
| 144 | : Promise.resolve(null); |
| 145 | const commitP = soft(repos.log(path, viewer, null, 1)).then((log) => (log?.ok ? (log.value[0] ?? null) : null)); |
| 146 | const range = resolveRange("30d", {}, new Date(now)); |
| 147 | const insightsP = repoP.then((repo) => (repo?.ok ? forMembers(() => work.deliveryInsights([repo.value.id], viewer, range.from, range.until)) : null)); |
| 148 | |
| 149 | const [ |
| 150 | { insider: member, can }, |
| 151 | project, |
| 152 | settings, |
| 153 | list, |
| 154 | open, |
| 155 | counts, |
| 156 | items, |
| 157 | claims, |
| 158 | active, |
| 159 | idle, |
| 160 | mine, |
| 161 | packageList, |
| 162 | tagList, |
| 163 | insights, |
| 164 | workflowRuns, |
| 165 | observed, |
| 166 | commit, |
| 167 | environments, |
| 168 | github, |
| 169 | ] = await Promise.all([ |
| 170 | accessP, |
| 171 | projectP, |
| 172 | forMembers(() => deployments.settings(ref, viewer)), |
| 173 | listP, |
| 174 | openP, |
| 175 | soft(countsFor(context, params)), |
| 176 | // Its open work items: those linked to it, in any role. |
| 177 | soft(work.listWorkItems(params.owner.toLowerCase(), viewer, { state: "open", repo: path })), |
| 178 | // Who is working on it right now, as Sessions shows them. |
| 179 | soft(work.listClaims(params.owner.toLowerCase(), viewer, { repo: path })), |
| 180 | soft(work.listWorkSessions(path, viewer, { state: "active" })), |
| 181 | soft(work.listWorkSessions(path, viewer, { state: "idle" })), |
| 182 | minePullsP, |
| 183 | packagesP, |
| 184 | // The latest release, for About. |
| 185 | soft(repos.tags(path, viewer)), |
| 186 | insightsP, |
| 187 | workflowRunsP, |
| 188 | telemetryP, |
| 189 | commitP, |
| 190 | environmentsP, |
| 191 | githubP, |
| 192 | ]); |
| 193 | const ok = <T,>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null); |
| 194 | |
| 195 | const openPulls = ok(open) ?? []; |
| 196 | const builds = ok(list)?.deployments ?? []; |
| 197 | const lifecycleOf = new Map((mine ?? []).map((item) => [item.pull.number, item.lifecycle])); |
| 198 | |
| 199 | // --- Needs you --------------------------------------------------------------- |
| 200 | const base = `/${params.owner}/${params.repo}`; |
| 201 | const needs: Need[] = []; |
| 202 | const latestProduction = builds.find((build) => build.kind === "production") ?? null; |
| 203 | if (latestProduction?.status === "failed") { |
| 204 | needs.push({ |
| 205 | key: "deploy", |
| 206 | kind: "deploy", |
| 207 | title: "The production build failed", |
| 208 | detail: latestProduction.error ?? "Production still serves the build before it.", |
| 209 | to: `${base}/deployments/${latestProduction.id}`, |
| 210 | action: "See the build", |
| 211 | at: Date.parse(latestProduction.finishedAt ?? latestProduction.createdAt), |
| 212 | where: latestProduction.commit.slice(0, 7), |
| 213 | }); |
| 214 | } |
| 215 | const me = viewer?.username.toLowerCase(); |
| 216 | for (const pull of openPulls) { |
| 217 | const to = `${base}/pull/${pull.number}`; |
| 218 | const where = `#${pull.number}`; |
| 219 | const lifecycle = lifecycleOf.get(pull.number); |
| 220 | if (lifecycle?.stage === "needs_you") { |
| 221 | const conflict = /conflict/i.test(lifecycle.detail); |
| 222 | needs.push({ |
| 223 | key: `pull:${pull.number}`, |
| 224 | kind: conflict ? "conflict" : "stalled", |
| 225 | title: pull.title, |
| 226 | detail: lifecycle.detail, |
| 227 | to, |
| 228 | action: conflict ? "Resolve" : "Decide", |
| 229 | at: Date.parse(pull.updatedAt), |
| 230 | where, |
| 231 | }); |
| 232 | } else if (lifecycle?.stage === "ready") { |
| 233 | needs.push({ |
| 234 | key: `pull:${pull.number}`, |
| 235 | kind: "ready", |
| 236 | title: pull.title, |
| 237 | detail: "Ready to land when you merge it.", |
| 238 | to, |
| 239 | action: "Merge", |
| 240 | at: Date.parse(pull.updatedAt), |
| 241 | where, |
| 242 | }); |
| 243 | } |
| 244 | // Never your own, nor one g1t made for you; whoever it is for asked. |
| 245 | const owner = workOwner(pull).username; |
| 246 | if (me && pull.status === "open" && owner.toLowerCase() !== me && pull.reviewers.some((name) => name.toLowerCase() === me)) { |
| 247 | needs.push({ |
| 248 | key: `review:${pull.number}`, |
| 249 | kind: "review", |
| 250 | title: pull.title, |
| 251 | detail: `${owner} asked for your review.`, |
| 252 | to, |
| 253 | action: "Review", |
| 254 | at: Date.parse(pull.updatedAt), |
| 255 | where, |
| 256 | }); |
| 257 | } |
| 258 | if (member && pull.status === "open" && pull.checkStatus === "failed" && !lifecycle) { |
| 259 | needs.push({ |
| 260 | key: `checks:${pull.number}`, |
| 261 | kind: "checks", |
| 262 | title: pull.title, |
| 263 | detail: "Its checks failed.", |
| 264 | to, |
| 265 | action: "See checks", |
| 266 | at: Date.parse(pull.updatedAt), |
| 267 | where, |
| 268 | }); |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // --- Health ------------------------------------------------------------------ |
| 273 | const openItems = ok(items); |
| 274 | |
| 275 | // --- Live now ------------------------------------------------------------------ |
| 276 | const titles: Record<number, string> = {}; |
| 277 | for (const item of openItems ?? []) titles[item.number] = item.title; |
| 278 | // For sessions on a pull request or a branch of their own, with no work item yet. |
| 279 | const livePulls: Record<number, LivePull> = {}; |
| 280 | for (const pull of openPulls) livePulls[pull.number] = { title: pull.title, status: pull.status }; |
| 281 | const repoValue = await repoP; |
| 282 | const defaultBranch = repoValue?.ok ? repoValue.value.defaultBranch : null; |
| 283 | |
| 284 | // --- Where it runs ------------------------------------------------------------ |
| 285 | const environmentList = ok(environments); |
| 286 | // Production deployed somewhere other than g1t.page (by g1t Actions, or |
| 287 | // reported through the API): the slot an app deployed elsewhere shows. |
| 288 | const deployment: ExternalDeployment | null = reportedProduction(environmentList?.environments ?? []); |
| 289 | // An app that has not said where it runs, but reports production from |
| 290 | // elsewhere, runs elsewhere. |
| 291 | const found = ok(project); |
| 292 | const projectValue = found && deployment && found.kind === "app" && found.runs == null ? { ...found, runs: "elsewhere" as const } : found; |
| 293 | const isLibrary = projectValue != null && projectPlan(projectValue) === "release"; |
| 294 | const published = libraryPackages(ok(packageList) ?? []); |
| 295 | const live = ok(list)?.live ?? []; |
| 296 | // Production's newest deployment, wherever it ran: the head's status for everyone. |
| 297 | const productionEnv = productionEnvironment(environmentList?.environments ?? []); |
| 298 | const productionLatest = productionEnv?.latest ?? null; |
| 299 | // What production serves on g1t.page. Members have it from the builds; |
| 300 | // anyone else from the production environment, which they may read. |
| 301 | const liveProduction = live.find((app) => app.kind === "production") ?? null; |
| 302 | const current = productionEnv?.current ?? null; |
| 303 | const production = |
| 304 | nativeProduction(environmentList?.environments ?? []) ?? |
| 305 | (liveProduction ? { url: liveProduction.url, commit: liveProduction.commit, deployedAt: liveProduction.deployedAt } : null); |
| 306 | const telemetryData = observed?.overview && (observed.overview.totals.requests > 0 || (observed.errors?.length ?? 0) > 0) ? observed : null; |
| 307 | |
| 308 | return { |
| 309 | member, |
| 310 | workspaceMember: roleIn(viewer, params.owner) != null, |
| 311 | can, |
| 312 | github, |
| 313 | project: projectValue, |
| 314 | settings: ok(settings), |
| 315 | builds: builds.slice(0, 30), |
| 316 | previews: live.filter((app) => app.kind === "preview"), |
| 317 | production, |
| 318 | serving: current ? { id: current.id, sha: current.sha, ref: current.ref, at: current.updated_at, creator: current.creator, runUrl: current.run_url } : null, |
| 319 | // Every reader can see production; administrative builds and settings remain member-only. |
| 320 | productionShot: production ? shotVersion(production) : null, |
| 321 | latestProductionBuild: latestProduction ? { status: latestProduction.status } : null, |
| 322 | productionLatest: productionLatest |
| 323 | ? { id: productionLatest.id, sha: productionLatest.sha, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name } |
| 324 | : null, |
| 325 | commit: commit ? { hash: commit.hash, authoredAt: commit.authoredAt } : null, |
| 326 | // The commit's checks, streamed in after the page. |
| 327 | checks: commitChecksFor(path, viewer, [commit?.hash, current?.sha, production?.commit]), |
| 328 | workflowRuns: ok(workflowRuns), |
| 329 | insights, |
| 330 | range, |
| 331 | release: latestTag(ok(tagList) ?? []), |
| 332 | // Production as reported from outside g1t, for an app deployed elsewhere. |
| 333 | deployment, |
| 334 | // Its screenshot's address on the site (production-elsewhere.ts), when it has one to show. |
| 335 | elsewhereShot: elsewhereShot(projectValue, deployment, base), |
| 336 | // Every environment's latest deployment, for the Deployments panel. |
| 337 | environments: environmentList, |
| 338 | open: ok(counts) ?? { items: openItems?.length ?? 0, pulls: openPulls.length }, |
| 339 | needs: rankNeeds(needs), |
| 340 | claims: ok(claims) ?? [], |
| 341 | sessions: [...(ok(active) ?? []), ...(ok(idle) ?? [])], |
| 342 | // Who may end a session by hand from Live now: its own runner, or anyone with write access. |
| 343 | ender: { viewerId: viewer?.id ?? null, canPush: can.push }, |
| 344 | liveLoaded: claims?.ok === true || active?.ok === true, |
| 345 | // Sections that could not be read say so in their place, rather than |
| 346 | // reading as empty: what needs someone signed in (their open pull |
| 347 | // requests), the deployments panel, and a member's observability. |
| 348 | needsLoaded: viewer == null || open != null, |
| 349 | environmentsLoaded: environments != null, |
| 350 | telemetryLoaded: observed == null || observed.overview != null, |
| 351 | titles, |
| 352 | livePulls, |
| 353 | defaultBranch, |
| 354 | health: { |
| 355 | ...runHealth(ok(workflowRuns) ?? []), |
| 356 | ages: openItems |
| 357 | ? ageBuckets( |
| 358 | openItems.map((item) => item.createdAt), |
| 359 | now, |
| 360 | ) |
| 361 | : null, |
| 362 | }, |
| 363 | telemetry: telemetryData ? { overview: telemetryData.overview!, errors: telemetryData.errors ?? [] } : null, |
| 364 | library: isLibrary ? { packages: published.slice(0, PACKAGES_SHOWN), total: published.length, loaded: packageList?.ok ?? false } : null, |
| 365 | }; |
| 366 | } |
| 367 | |
| 368 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 369 | assertSameOrigin(request); |
| 370 | const user = requireUser(context, request); |
| 371 | const form = await request.formData().catch(() => null); |
| 372 | const intent = form?.get("intent"); |
| 373 | // What it is, where it runs, its description and links: its settings. |
| 374 | if (form && (intent === "kind" || intent === "about")) { |
| 375 | const refused = await refusal(context, params, "manage_settings"); |
| 376 | if (refused) return { error: refused }; |
| 377 | const saved = await projects.update(user, params.owner, params.repo, projectChanges(form)); |
| 378 | return saved.ok ? { notice: "Saved." } : { error: saved.error.message }; |
| 379 | } |
| 380 | // Deploying is compute. |
| 381 | const refused = await refusal(context, params, "run"); |
| 382 | if (refused) return { error: refused }; |
| 383 | const started = await deployments.redeploy(user, { workspace: params.owner, slug: params.repo }, null); |
| 384 | return started.ok ? { notice: "Production is building." } : { error: started.error.message }; |
| 385 | } |
| 386 | |
| 387 | type Loaded = Awaited<ReturnType<typeof overviewData>>; |
| 388 | |
| 389 | /** |
| 390 | * Where an app's overview shows production, a library's shows what it |
| 391 | * publishes: each package with its latest version and how to install it, |
| 392 | * or, before there is one, how to publish a first version. |
| 393 | */ |
| 394 | function LibraryHead({ |
| 395 | library, |
| 396 | project, |
| 397 | base, |
| 398 | workspace, |
| 399 | }: { |
| 400 | library: { packages: PackageSummary[]; total: number; loaded: boolean }; |
| 401 | project: Project | null; |
| 402 | base: string; |
| 403 | workspace: string; |
| 404 | }) { |
| 405 | const guide = publishGuide(project?.ecosystem ?? null); |
| 406 | const name = project?.name ?? base.split("/").pop(); |
| 407 | return ( |
| 408 | <div className="p-5 sm:p-6"> |
| 409 | <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase"> |
| 410 | <PackageGlyph size={13} className="text-accent" /> |
| 411 | Packages |
| 412 | </p> |
| 413 | {library.packages.length > 0 ? ( |
| 414 | <> |
| 415 | <ul className="mt-3 space-y-4"> |
| 416 | {library.packages.map((pkg) => { |
| 417 | const commands = installCommands(pkg, pkg.latest, "you"); |
| 418 | return ( |
| 419 | <li key={pkg.id} className="flex flex-col gap-2.5 lg:flex-row lg:items-start lg:gap-5"> |
| 420 | <Link to={packagePath(pkg)} className="group flex min-w-0 items-center gap-3 lg:w-72 lg:shrink-0"> |
| 421 | <PackageIcon ecosystem={pkg.ecosystem} size={32} /> |
| 422 | <span className="min-w-0"> |
| 423 | <span className="block truncate font-mono text-[0.9375rem] font-medium group-hover:text-accent">{packageName(pkg)}</span> |
| 424 | <span className="block text-xs text-muted"> |
| 425 | {ECOSYSTEM_LABEL[pkg.ecosystem]} · {pkg.latest ?? "no versions yet"} |
| 426 | {pkg.versions > 1 && ` · ${pkg.versions} versions`} |
| 427 | </span> |
| 428 | </span> |
| 429 | </Link> |
| 430 | <div className="min-w-0 grow space-y-1.5"> |
| 431 | {/* The workspace's registry first, or the tool installs a package of the same name from elsewhere. */} |
| 432 | {commands.registry && <CopyLine prompt text={commands.registry} />} |
| 433 | <CopyLine prompt text={commands.install} /> |
| 434 | </div> |
| 435 | </li> |
| 436 | ); |
| 437 | })} |
| 438 | </ul> |
| 439 | {library.total > library.packages.length && ( |
| 440 | <Link to={`/${workspace}/-/packages`} className="mt-3 inline-block text-xs text-muted hover:text-fg"> |
| 441 | All {library.total} packages |
| 442 | </Link> |
| 443 | )} |
| 444 | </> |
| 445 | ) : ( |
| 446 | <> |
| 447 | <p className="mt-2 text-lg font-medium">{library.loaded ? "Not published yet" : "Packages could not be loaded just now"}</p> |
| 448 | {guide ? ( |
| 449 | <> |
| 450 | <p className="mt-1 max-w-lg text-sm text-muted"> |
| 451 | Publish a first version of {name} to the workspace's {guide.label} registry, for others to install |
| 452 | {guide.start ? ":" : "."} |
| 453 | </p> |
| 454 | {guide.start && ( |
| 455 | <div className="mt-3 max-w-lg"> |
| 456 | <CopyLine prompt text={guide.start} /> |
| 457 | </div> |
| 458 | )} |
| 459 | <a href={guide.guide} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"> |
| 460 | Publishing to {guide.label} <ArrowUpRight size={13} /> |
| 461 | </a> |
| 462 | </> |
| 463 | ) : ( |
| 464 | <> |
| 465 | <p className="mt-1 max-w-lg text-sm text-muted">Publish a first version of {name} to one of the workspace's registries:</p> |
| 466 | <p className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm"> |
| 467 | {PUBLISH_GUIDES.map((one) => ( |
| 468 | <a key={one.label} href={one.guide} className="inline-flex items-center gap-1 text-accent hover:underline"> |
| 469 | {one.label} <ArrowUpRight size={13} /> |
| 470 | </a> |
| 471 | ))} |
| 472 | </p> |
| 473 | </> |
| 474 | )} |
| 475 | </> |
| 476 | )} |
| 477 | </div> |
| 478 | ); |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * Where the overview asks for the screenshot of production deployed |
| 483 | * elsewhere, with `?v=` naming the reported deploy or the day; null when |
| 484 | * there is none to show (lib/production-image.ts `externalTarget`). |
| 485 | */ |
| 486 | function elsewhereShot(project: Project | null | undefined, deployment: ExternalDeployment | null, base: string): string | null { |
| 487 | const target = project ? externalTarget(project, deployment, Date.now()) : null; |
| 488 | return target ? `${base}/production-elsewhere.jpg?v=${externalVersion(target)}` : null; |
| 489 | } |
| 490 | |
| 491 | export default function ProjectOverview({ loaderData, actionData, params }: Route.ComponentProps) { |
| 492 | return ( |
| 493 | <Suspense fallback={<OverviewSkeleton />}> |
| 494 | <Await resolve={loaderData.overview} errorElement={<OverviewFailed />}> |
| 495 | {(overview) => ( |
| 496 | // Faded in over its outline, not switched for it. |
| 497 | <div className="animate-fade-in"> |
| 498 | <Overview loaderData={overview} actionData={actionData} params={params} /> |
| 499 | </div> |
| 500 | )} |
| 501 | </Await> |
| 502 | </Suspense> |
| 503 | ); |
| 504 | } |
| 505 | |
| 506 | /** The overview's sections as they will sit, while they stream in. */ |
| 507 | function OverviewSkeleton() { |
| 508 | return ( |
| 509 | <Loading className="space-y-8" aria-label="Loading the overview"> |
| 510 | <Card className="overflow-hidden rounded-2xl"> |
| 511 | <div className="border-b border-line p-5"> |
| 512 | <Skeleton className="h-5 w-48" /> |
| 513 | </div> |
| 514 | <div className="grid gap-6 p-6 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> |
| 515 | <Skeleton className="aspect-16/10 w-full rounded-lg" /> |
| 516 | <SkeletonRows rows={5} /> |
| 517 | </div> |
| 518 | </Card> |
| 519 | <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> |
| 520 | {[0, 1, 2].map((key) => ( |
| 521 | <Card key={key} className="p-5"> |
| 522 | <SkeletonRows rows={4} /> |
| 523 | </Card> |
| 524 | ))} |
| 525 | </div> |
| 526 | </Loading> |
| 527 | ); |
| 528 | } |
| 529 | |
| 530 | function OverviewFailed() { |
| 531 | return ( |
| 532 | <Card asChild className="rounded-2xl p-6 text-sm text-muted"> |
| 533 | <p>The overview could not be loaded just now. Reload the page to try again.</p> |
| 534 | </Card> |
| 535 | ); |
| 536 | } |
| 537 | |
| 538 | function plural(n: number, one: string, many = `${one}s`): string { |
| 539 | return `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; |
| 540 | } |
| 541 | |
| 542 | /** One line to the repository, where its files, branches and commits are: Code. */ |
| 543 | function CodeLine({ project, base }: { project: Project; base: string }) { |
| 544 | if (project.source.kind !== "hosted") return null; |
| 545 | const { repo, rootDir } = project.source; |
| 546 | return ( |
| 547 | <Link |
| 548 | to={`${base}/code`} |
| 549 | prefetch="intent" |
| 550 | className="group flex min-w-0 items-center gap-2 border-t border-line px-5 py-2.5 text-xs text-muted transition-colors hover:bg-raised/50 hover:text-fg sm:px-6" |
| 551 | > |
| 552 | {project.private ? <Lock size={12} className="shrink-0 text-faint" /> : <Code2 size={12} className="shrink-0 text-faint" />} |
| 553 | <span className="min-w-0 truncate font-mono text-fg-soft"> |
| 554 | {repo.namespace}/{repo.name} |
| 555 | {rootDir ? `/${rootDir}` : ""} |
| 556 | </span> |
| 557 | {project.pushedAt && ( |
| 558 | <span className="hidden shrink-0 md:inline"> |
| 559 | · pushed <TimeAgo at={project.pushedAt} /> |
| 560 | </span> |
| 561 | )} |
| 562 | <span className="grow" /> |
| 563 | <span className="inline-flex shrink-0 items-center gap-1 font-medium group-hover:text-accent"> |
| 564 | Code <ArrowRight size={12} /> |
| 565 | </span> |
| 566 | </Link> |
| 567 | ); |
| 568 | } |
| 569 | |
| 570 | /** Production on g1t.page: its picture, address and state. Actions live in the card header. */ |
| 571 | function ProductionHead({ |
| 572 | loaderData, |
| 573 | base, |
| 574 | name, |
| 575 | upload, |
| 576 | addImage, |
| 577 | }: { |
| 578 | loaderData: Loaded; |
| 579 | base: string; |
| 580 | name: string; |
| 581 | upload: { url: string; preferred: boolean } | null; |
| 582 | addImage: string | null; |
| 583 | }) { |
| 584 | const { member, settings, production, latestProductionBuild, productionLatest } = loaderData; |
| 585 | // The project's own domain, once it is active, is where production is visited. |
| 586 | const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null; |
| 587 | return ( |
| 588 | <div className="flex flex-col gap-5 p-5 sm:flex-row sm:items-start sm:p-6"> |
| 589 | {/* Always a picture: the screenshot (members, like the deployments), the uploaded image, or the frame. */} |
| 590 | <ProductionShot |
| 591 | sources={cardSources(loaderData.productionShot ? `${base}/production.jpg?v=${loaderData.productionShot}` : null, upload)} |
| 592 | href={productionUrl} |
| 593 | label={productionUrl ? host(productionUrl) : settings?.productionUrl ? host(settings.productionUrl) : ""} |
| 594 | note={!production && settings?.enabled ? "A screenshot appears after the next deploy" : null} |
| 595 | addImage={addImage} |
| 596 | className="w-full shrink-0 sm:w-60 lg:w-72" |
| 597 | /> |
| 598 | <div className="flex min-w-0 grow flex-wrap items-start justify-between gap-4"> |
| 599 | <div className="min-w-0"> |
| 600 | <p className="flex items-center gap-2 text-xs font-medium tracking-wide text-muted uppercase"> |
| 601 | <Rocket size={13} className="text-accent" /> |
| 602 | Production |
| 603 | </p> |
| 604 | {production && productionUrl ? ( |
| 605 | <> |
| 606 | <DeployLink href={productionUrl} className="mt-2 flex items-center gap-1.5 truncate font-mono text-lg font-medium hover:text-accent"> |
| 607 | {host(productionUrl)} |
| 608 | <ArrowUpRight size={16} className="shrink-0 text-faint" /> |
| 609 | </DeployLink> |
| 610 | <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"> |
| 611 | {latestProductionBuild ? ( |
| 612 | <StatusDot status={latestProductionBuild.status} label={latestProductionBuild.status === "ready" ? "Live" : undefined} /> |
| 613 | ) : ( |
| 614 | // Anyone else has the production environment's newest deployment. |
| 615 | productionLatest && ( |
| 616 | <StatusDot status={deploymentStatus(productionLatest.state)} label={productionLatest.state === "success" ? "Live" : undefined} /> |
| 617 | ) |
| 618 | )} |
| 619 | <span className="inline-flex items-center gap-1 font-mono"> |
| 620 | <GitCommitHorizontal size={13} className="text-faint" /> |
| 621 | {production.commit.slice(0, 7)} |
| 622 | </span> |
| 623 | <span> |
| 624 | deployed <TimeAgo at={production.deployedAt} /> |
| 625 | </span> |
| 626 | {settings?.primaryDomain && <span className="text-faint">also at {host(production.url)}</span>} |
| 627 | </p> |
| 628 | </> |
| 629 | ) : settings?.enabled ? ( |
| 630 | <> |
| 631 | <p className="mt-2 font-mono text-lg text-muted">{host(settings.productionUrl)}</p> |
| 632 | <p className="mt-1.5 text-xs text-muted"> |
| 633 | {latestProductionBuild ? ( |
| 634 | <StatusDot status={latestProductionBuild.status} label={latestProductionBuild.status === "failed" ? "The last build failed" : undefined} /> |
| 635 | ) : ( |
| 636 | "Not deployed yet. Push to the default branch, or deploy it now." |
| 637 | )} |
| 638 | </p> |
| 639 | </> |
| 640 | ) : member ? ( |
| 641 | <> |
| 642 | <p className="mt-2 text-lg font-medium">Deployments are off</p> |
| 643 | <p className="mt-1 max-w-lg text-sm text-muted"> |
| 644 | Nothing builds or runs until you turn them on. Then {name} goes up on g1t.page: production from the default branch, and a live preview for every |
| 645 | pull request. It runs only while someone visits. |
| 646 | </p> |
| 647 | </> |
| 648 | ) : ( |
| 649 | <> |
| 650 | <p className="mt-2 text-lg font-medium">Not deployed</p> |
| 651 | <p className="mt-1 max-w-lg text-sm text-muted">{name} has no production deployment to visit yet.</p> |
| 652 | </> |
| 653 | )} |
| 654 | </div> |
| 655 | </div> |
| 656 | </div> |
| 657 | ); |
| 658 | } |
| 659 | |
| 660 | /** The running deployment owns these fields; a newer attempt never changes its status. */ |
| 661 | function ProductionSummary({ |
| 662 | loaderData: d, |
| 663 | base, |
| 664 | upload, |
| 665 | addImage, |
| 666 | elsewhere, |
| 667 | }: { |
| 668 | loaderData: Loaded; |
| 669 | base: string; |
| 670 | upload: { url: string; preferred: boolean } | null; |
| 671 | addImage: string | null; |
| 672 | elsewhere: boolean; |
| 673 | }) { |
| 674 | const current = d.serving; |
| 675 | const sha = current?.sha ?? d.production?.commit; |
| 676 | const url = elsewhere |
| 677 | ? (d.deployment?.url ?? d.project?.productionUrl) |
| 678 | : d.settings?.primaryDomain |
| 679 | ? `https://${d.settings.primaryDomain}` |
| 680 | : d.production?.url; |
| 681 | const at = current?.at ?? d.production?.deployedAt; |
| 682 | const latest = d.productionLatest; |
| 683 | const newerAttempt = latest && hasSeparateAttempt(current?.id, sha, latest); |
| 684 | const screenshot = elsewhere ? d.elsewhereShot : d.productionShot ? `${base}/production.jpg?v=${d.productionShot}` : null; |
| 685 | return ( |
| 686 | <> |
| 687 | <div className="grid gap-6 p-5 sm:p-6 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] lg:gap-8"> |
| 688 | <ProductionShot |
| 689 | sources={cardSources(screenshot, upload)} |
| 690 | href={url ?? null} |
| 691 | label={url ? host(url) : ""} |
| 692 | addImage={addImage} |
| 693 | className="w-full self-start" |
| 694 | /> |
| 695 | <dl className="grid min-w-0 content-start grid-cols-2 gap-x-5 gap-y-5 text-sm"> |
| 696 | <div className="col-span-2 min-w-0"> |
| 697 | <dt className="text-xs text-muted">Deployment</dt> |
| 698 | <dd className="mt-1 break-words font-medium"> |
| 699 | {current ? ( |
| 700 | <Link to={`${base}/deployments/${current.id}`} className="hover:text-accent"> |
| 701 | Current production <ArrowUpRight size={14} className="inline text-faint" /> |
| 702 | </Link> |
| 703 | ) : ( |
| 704 | <span>Production</span> |
| 705 | )} |
| 706 | </dd> |
| 707 | </div> |
| 708 | <div className="col-span-2 min-w-0"> |
| 709 | <dt className="text-xs text-muted">Domain</dt> |
| 710 | <dd className="mt-1"> |
| 711 | {url ? ( |
| 712 | <DeployLink href={url} className="inline-flex max-w-full items-center gap-1.5 font-medium hover:text-accent"> |
| 713 | <span className="truncate">{host(url)}</span> |
| 714 | <ArrowUpRight size={14} className="shrink-0 text-faint" /> |
| 715 | </DeployLink> |
| 716 | ) : ( |
| 717 | <span className="text-muted">No address reported</span> |
| 718 | )} |
| 719 | </dd> |
| 720 | {!elsewhere && d.settings?.primaryDomain && d.production && ( |
| 721 | <dd className="mt-1 text-xs text-muted"> |
| 722 | <DeployLink href={d.production.url} className="break-all hover:text-fg"> |
| 723 | {host(d.production.url)} |
| 724 | </DeployLink> |
| 725 | </dd> |
| 726 | )} |
| 727 | </div> |
| 728 | <div> |
| 729 | <dt className="text-xs text-muted">Status</dt> |
| 730 | <dd className="mt-1"> |
| 731 | <StatusDot status="ready" label="Ready" /> |
| 732 | </dd> |
| 733 | </div> |
| 734 | <div> |
| 735 | <dt className="text-xs text-muted">Deployed</dt> |
| 736 | <dd className="mt-1 text-xs"> |
| 737 | {at && <TimeAgo at={at} />} |
| 738 | {current?.creator && <span className="text-muted"> by {current.creator}</span>} |
| 739 | </dd> |
| 740 | </div> |
| 741 | <div className="col-span-2 min-w-0"> |
| 742 | <dt className="text-xs text-muted">Source</dt> |
| 743 | <dd className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-2"> |
| 744 | {current?.ref && current.ref !== sha && ( |
| 745 | <Link to={`${base}/tree/${encodeURIComponent(current.ref)}`} className="inline-flex min-w-0 items-center gap-1.5 hover:text-accent"> |
| 746 | <GitBranch size={14} className="shrink-0 text-faint" /> |
| 747 | <span className="truncate">{current.ref}</span> |
| 748 | </Link> |
| 749 | )} |
| 750 | {sha && ( |
| 751 | <Link to={`${base}/commit/${sha}`} className="inline-flex items-center gap-1.5 font-mono text-xs hover:text-accent"> |
| 752 | <GitCommitHorizontal size={14} className="text-faint" /> |
| 753 | {sha.slice(0, 7)} |
| 754 | </Link> |
| 755 | )} |
| 756 | {current?.runUrl && ( |
| 757 | <Link to={current.runUrl} className="text-xs text-muted hover:text-fg"> |
| 758 | View workflow <ArrowUpRight size={12} className="inline" /> |
| 759 | </Link> |
| 760 | )} |
| 761 | </dd> |
| 762 | {sha && sha !== d.commit?.hash && ( |
| 763 | <dd className="mt-3"> |
| 764 | <CommitChecksSummary checks={d.checks} sha={sha} label="Checks for deployed commit" /> |
| 765 | </dd> |
| 766 | )} |
| 767 | </div> |
| 768 | </dl> |
| 769 | </div> |
| 770 | {newerAttempt && ( |
| 771 | <Link |
| 772 | to={`${base}/deployments/${latest.id}`} |
| 773 | className="flex flex-wrap items-center gap-2 border-t border-line bg-raised/30 px-5 py-3 text-xs hover:bg-raised/60 sm:px-6" |
| 774 | > |
| 775 | <DeploymentStateIcon state={latest.state} size={14} /> |
| 776 | <span>Latest attempt: {STATE_WORD[latest.state]}</span> |
| 777 | <span className="font-mono text-muted">{latest.sha.slice(0, 7)}</span> |
| 778 | <span className="text-muted"> |
| 779 | <TimeAgo at={latest.at} /> |
| 780 | </span> |
| 781 | <span className="ml-auto text-muted"> |
| 782 | View deployment <ArrowRight size={12} className="inline" /> |
| 783 | </span> |
| 784 | </Link> |
| 785 | )} |
| 786 | </> |
| 787 | ); |
| 788 | } |
| 789 | |
| 790 | /** Branch health belongs to the repository head, independently of what is deployed. */ |
| 791 | function BranchCard({ loaderData: d, base }: { loaderData: Loaded; base: string }) { |
| 792 | const branch = projectCheckSections(d.commit?.hash, d.defaultBranch, null)[0]; |
| 793 | return ( |
| 794 | <Card asChild className="min-w-0 p-5"> |
| 795 | <section aria-labelledby="overview-branch"> |
| 796 | <h2 id="overview-branch" className="flex items-center gap-2 text-sm font-semibold"> |
| 797 | <GitBranch size={14} className="text-faint" /> |
| 798 | Latest on {d.defaultBranch ?? "default branch"} |
| 799 | </h2> |
| 800 | {branch ? ( |
| 801 | <> |
| 802 | <div className="my-4 flex flex-wrap items-center gap-2 text-xs"> |
| 803 | <Link to={`${base}/commit/${branch.sha}`} className="font-mono hover:text-accent"> |
| 804 | {branch.sha.slice(0, 7)} |
| 805 | </Link> |
| 806 | <span className="text-muted">committed {d.commit && <TimeAgo at={d.commit.authoredAt} />}</span> |
| 807 | </div> |
| 808 | <CommitChecksSummary checks={d.checks} sha={branch.sha} /> |
| 809 | </> |
| 810 | ) : ( |
| 811 | <p className="my-4 text-xs text-muted">The latest commit is unavailable.</p> |
| 812 | )} |
| 813 | <dl className="mt-4 space-y-3 border-t border-line pt-4 text-xs"> |
| 814 | <div className="flex justify-between gap-3"> |
| 815 | <dt className="text-muted">Latest release</dt> |
| 816 | <dd> |
| 817 | <Link to={`${base}/tags`} className="font-mono hover:text-accent"> |
| 818 | {d.release?.name ?? "None yet"} |
| 819 | </Link> |
| 820 | {d.release?.at && ( |
| 821 | <span className="ml-2 text-muted"> |
| 822 | <TimeAgo at={d.release.at} /> |
| 823 | </span> |
| 824 | )} |
| 825 | </dd> |
| 826 | </div> |
| 827 | <div className="flex justify-between gap-3"> |
| 828 | <dt className="text-muted">Open pull requests</dt> |
| 829 | <dd> |
| 830 | <Link to={`${base}/pulls`} className="tabular-nums hover:text-accent"> |
| 831 | {d.open.pulls} |
| 832 | </Link> |
| 833 | </dd> |
| 834 | </div> |
| 835 | </dl> |
| 836 | </section> |
| 837 | </Card> |
| 838 | ); |
| 839 | } |
| 840 | |
| 841 | /** Production controls share a header, whether g1t or a workflow hosts it. */ |
| 842 | function ProductionActions({ loaderData, base, elsewhere }: { loaderData: Loaded; base: string; elsewhere: boolean }) { |
| 843 | const { member, settings, production, project, can } = loaderData; |
| 844 | const url = elsewhere |
| 845 | ? (loaderData.deployment?.url ?? project?.productionUrl) |
| 846 | : production |
| 847 | ? settings?.primaryDomain |
| 848 | ? `https://${settings.primaryDomain}` |
| 849 | : production.url |
| 850 | : null; |
| 851 | return ( |
| 852 | <div className="flex flex-wrap items-center gap-2"> |
| 853 | {member && (can.run || can.manage_integrations) && ( |
| 854 | <Hint disabled label="Instant rollback is coming soon. It is not available yet."> |
| 855 | <Button disabled variant="outline" size="sm"> |
| 856 | <RotateCcw size={14} /> |
| 857 | Instant rollback <SoonPill /> |
| 858 | </Button> |
| 859 | </Hint> |
| 860 | )} |
| 861 | {!elsewhere && member && settings?.enabled && can.run && ( |
| 862 | <Form method="post"> |
| 863 | <Hint label="Build production again from the default branch"> |
| 864 | <SubmitButton variant="outline" size="sm" name="intent" value="redeploy" pending="Redeploying…"> |
| 865 | <RotateCw size={14} /> Redeploy |
| 866 | </SubmitButton> |
| 867 | </Hint> |
| 868 | </Form> |
| 869 | )} |
| 870 | {!elsewhere && member && !settings?.enabled && can.manage_integrations && ( |
| 871 | <ButtonLink to={`${base}/settings/deployments`} variant="accent" size="sm"> |
| 872 | <Rocket size={14} /> Turn on deployments |
| 873 | </ButtonLink> |
| 874 | )} |
| 875 | {url && ( |
| 876 | <ButtonLink to={url} variant="accent" size="sm" reloadDocument target="_blank" rel="noopener noreferrer"> |
| 877 | Visit <ArrowUpRight size={14} /> |
| 878 | </ButtonLink> |
| 879 | )} |
| 880 | </div> |
| 881 | ); |
| 882 | } |
| 883 | |
| 884 | function Overview({ |
| 885 | loaderData, |
| 886 | actionData, |
| 887 | params, |
| 888 | }: { |
| 889 | loaderData: Loaded; |
| 890 | actionData: Route.ComponentProps["actionData"]; |
| 891 | params: Route.ComponentProps["params"]; |
| 892 | }) { |
| 893 | const { member, project, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData; |
| 894 | const base = `/${params.owner}/${params.repo}`; |
| 895 | const name = project?.name ?? params.repo; |
| 896 | const plan = project ? projectPlan(project) : "production"; |
| 897 | const canChange = member && loaderData.can.manage_settings; |
| 898 | const deployedSha = loaderData.serving?.sha ?? loaderData.production?.commit; |
| 899 | const productionPlan = plan === "production" || plan === "elsewhere"; |
| 900 | const hasProduction = productionPlan && Boolean(deployedSha); |
| 901 | const addresses = useAddresses(); |
| 902 | // The production card's picture: an uploaded image, served from the |
| 903 | // usercontent origin, and where someone who may change it adds one. |
| 904 | const upload = project?.productionImage |
| 905 | ? { url: productionImageUrl(project.productionImage.file, addresses.usercontent), preferred: project.productionImage.preferred } |
| 906 | : null; |
| 907 | const addImage = canChange ? `${base}/settings#production-image` : null; |
| 908 | const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null; |
| 909 | // Idle sessions fold under the list (LiveNowList); the counts are of the rest. |
| 910 | const { awake, idle } = awakeAndIdle(sessions); |
| 911 | const busy = claims.length + awake.length > 0; |
| 912 | const working = busy || idle.length > 0; |
| 913 | // Deployments, checks, releases, claims and sessions show as they happen. |
| 914 | useRepoLive({ match: (change) => repoPages.overview()(change) || repoPages.runs()(change) || change.kind === "git.push" }); |
| 915 | // Claims lapse and sessions go idle with no event to say so. |
| 916 | useRefreshWhile(working || (loaderData.workflowRuns ?? []).some((run) => run.status !== "completed"), 15_000); |
| 917 | const live = liveByItem(claims, awake); |
| 918 | const moreItems = live.items.length - LIVE_SHOWN; |
| 919 | |
| 920 | return ( |
| 921 | <div className="space-y-8"> |
| 922 | {loaderData.github && <GithubLinkStrip link={loaderData.github} />} |
| 923 | {/* What the project is, whether it is running and healthy, and a line to its code. */} |
| 924 | <Card asChild className="overflow-hidden rounded-2xl"> |
| 925 | <section aria-label="Production"> |
| 926 | {(project || plan === "production") && ( |
| 927 | <div className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-5 py-3 text-xs sm:px-6"> |
| 928 | <div className="flex flex-wrap items-center gap-3"> |
| 929 | {(plan === "production" || plan === "elsewhere") && <h2 className="text-sm font-semibold">Production deployment</h2>} |
| 930 | {project && <KindMenu project={project} label={kindLabel(project)} canChange={canChange} />} |
| 931 | </div> |
| 932 | {(plan === "production" || plan === "elsewhere") && <ProductionActions loaderData={loaderData} base={base} elsewhere={plan === "elsewhere"} />} |
| 933 | </div> |
| 934 | )} |
| 935 | {hasProduction ? ( |
| 936 | <ProductionSummary loaderData={loaderData} base={base} upload={upload} addImage={addImage} elsewhere={plan === "elsewhere"} /> |
| 937 | ) : plan === "elsewhere" && project ? ( |
| 938 | <ElsewhereHead |
| 939 | project={project} |
| 940 | base={base} |
| 941 | canChange={canChange} |
| 942 | deployment={loaderData.deployment} |
| 943 | shot={ |
| 944 | <ProductionShot |
| 945 | sources={cardSources(loaderData.elsewhereShot, upload)} |
| 946 | href={elsewhereUrl} |
| 947 | label={elsewhereUrl ? host(elsewhereUrl).replace(/\/$/, "") : ""} |
| 948 | addImage={addImage} |
| 949 | className="w-full shrink-0 sm:w-60 lg:w-72" |
| 950 | /> |
| 951 | } |
| 952 | /> |
| 953 | ) : plan === "unknown" && project ? ( |
| 954 | <WhereItRuns project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} /> |
| 955 | ) : plan === "docs" && project ? ( |
| 956 | <DocsHead project={project} base={base} canChange={canChange} canDeploy={member && loaderData.can.manage_integrations} /> |
| 957 | ) : plan === "other" && project ? ( |
| 958 | <OtherHead project={project} base={base} canChange={canChange} /> |
| 959 | ) : library ? ( |
| 960 | <LibraryHead library={library} project={project} base={base} workspace={params.owner} /> |
| 961 | ) : ( |
| 962 | <ProductionHead loaderData={loaderData} base={base} name={name} upload={upload} addImage={addImage} /> |
| 963 | )} |
| 964 | {actionData && ( |
| 965 | <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-success"}`}> |
| 966 | {"error" in actionData ? actionData.error : actionData.notice} |
| 967 | </p> |
| 968 | )} |
| 969 | {productionPlan && ( |
| 970 | <div className="flex flex-wrap items-center justify-between gap-3 border-t border-line px-5 py-3 sm:px-6"> |
| 971 | <span className="text-xs text-muted"> |
| 972 | {plan === "elsewhere" ? ( |
| 973 | "Production is deployed by its own pipeline." |
| 974 | ) : loaderData.settings?.enabled === false ? ( |
| 975 | "Deployments are not enabled for this project." |
| 976 | ) : ( |
| 977 | <> |
| 978 | Push to <span className="font-mono text-fg-soft">{defaultBranch ?? "the default branch"}</span> to update production. |
| 979 | </> |
| 980 | )} |
| 981 | </span> |
| 982 | <div className="flex flex-wrap items-center gap-2"> |
| 983 | {member && loaderData.can.manage_integrations && ( |
| 984 | <ButtonLink to={`${base}/settings/deployments`} variant="ghost" size="sm"> |
| 985 | <Settings2 size={14} /> |
| 986 | Deployment settings |
| 987 | </ButtonLink> |
| 988 | )} |
| 989 | <ButtonLink to={`${base}/deployments`} variant="outline" size="sm"> |
| 990 | <Rocket size={14} /> |
| 991 | Deployments{loaderData.environments && <span className="text-muted">{loaderData.environments.total_count}</span>} |
| 992 | </ButtonLink> |
| 993 | </div> |
| 994 | </div> |
| 995 | )} |
| 996 | {project && <CodeLine project={project} base={base} />} |
| 997 | </section> |
| 998 | </Card> |
| 999 | |
| 1000 | <div className={`grid items-start gap-4 md:grid-cols-2 ${loaderData.telemetry || !loaderData.telemetryLoaded ? "xl:grid-cols-3" : ""}`}> |
| 1001 | <BranchCard loaderData={loaderData} base={base} /> |
| 1002 | {!loaderData.telemetryLoaded && <Unavailable what="Observability" compact />} |
| 1003 | {loaderData.telemetry && <ObservabilityCard overview={loaderData.telemetry.overview} errors={loaderData.telemetry.errors} base={base} />} |
| 1004 | <HealthCard loaderData={loaderData} base={base} plan={plan} /> |
| 1005 | </div> |
| 1006 | |
| 1007 | {previews.length > 0 && ( |
| 1008 | <Panel title="Preview deployments" icon={<GitBranch size={14} />} count={previews.length}> |
| 1009 | <Card asChild divided className="overflow-hidden"> |
| 1010 | <ul> |
| 1011 | {previews.map((app) => ( |
| 1012 | <li key={app.url} className="flex flex-wrap items-center gap-3 px-5 py-4 text-sm"> |
| 1013 | <GitBranch size={16} className="shrink-0 text-faint" /> |
| 1014 | <div className="min-w-0 flex-1"> |
| 1015 | <div className="flex flex-wrap items-center gap-2"> |
| 1016 | <span className="truncate font-mono text-[0.8125rem] font-medium">{app.branch}</span> |
| 1017 | {app.number != null && ( |
| 1018 | <Link to={`${base}/pull/${app.number}`} className="text-xs text-muted hover:text-accent"> |
| 1019 | #{app.number} |
| 1020 | </Link> |
| 1021 | )} |
| 1022 | </div> |
| 1023 | <DeployLink href={app.url} className="mt-1 block truncate text-xs text-muted hover:text-accent"> |
| 1024 | {host(app.url)} |
| 1025 | </DeployLink> |
| 1026 | </div> |
| 1027 | <span className="shrink-0 text-xs text-faint"> |
| 1028 | <TimeAgo at={app.deployedAt} /> |
| 1029 | </span> |
| 1030 | <ButtonLink to={app.url} variant="outline" size="sm" reloadDocument target="_blank" rel="noopener noreferrer"> |
| 1031 | Visit <ArrowUpRight size={14} /> |
| 1032 | </ButtonLink> |
| 1033 | </li> |
| 1034 | ))} |
| 1035 | </ul> |
| 1036 | </Card> |
| 1037 | </Panel> |
| 1038 | )} |
| 1039 | |
| 1040 | {/* Deployments panel (deployments-panel.tsx): each environment's latest deployment. */} |
| 1041 | {loaderData.environmentsLoaded ? ( |
| 1042 | <DeploymentsPanel |
| 1043 | base={base} |
| 1044 | summary={loaderData.environments} |
| 1045 | excludeEnvironment={productionPlan ? loaderData.productionLatest?.environment : undefined} |
| 1046 | className="rounded-xl border border-line bg-surface p-5" |
| 1047 | /> |
| 1048 | ) : ( |
| 1049 | <Unavailable what="Deployments" compact /> |
| 1050 | )} |
| 1051 | |
| 1052 | <div className="min-w-0 space-y-8"> |
| 1053 | {!loaderData.needsLoaded && ( |
| 1054 | <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />}> |
| 1055 | <Unavailable what="What needs you" /> |
| 1056 | </Panel> |
| 1057 | )} |
| 1058 | {loaderData.needsLoaded && needs.length > 0 && ( |
| 1059 | <Panel title="Needs you" icon={<Hand size={14} className="text-warn" />} count={needs.length}> |
| 1060 | <NeedsList needs={needs} limit={5} /> |
| 1061 | </Panel> |
| 1062 | )} |
| 1063 | |
| 1064 | <Panel |
| 1065 | title="Live now" |
| 1066 | icon={<Radio size={14} className={busy ? "text-success" : undefined} />} |
| 1067 | extra={ |
| 1068 | working ? ( |
| 1069 | <span className="text-xs text-muted"> |
| 1070 | {plural(awake.length, "session")} · {plural(claims.length, "claim")} |
| 1071 | {idle.length > 0 && ` · ${idle.length} idle`} |
| 1072 | </span> |
| 1073 | ) : null |
| 1074 | } |
| 1075 | all={{ to: sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] }), label: "Sessions" }} |
| 1076 | > |
| 1077 | {!loaderData.liveLoaded ? ( |
| 1078 | <Unavailable what="Who is working" /> |
| 1079 | ) : working ? ( |
| 1080 | <> |
| 1081 | <LiveNowList |
| 1082 | claims={claims} |
| 1083 | sessions={sessions} |
| 1084 | titles={titles} |
| 1085 | pulls={livePulls} |
| 1086 | defaultBranch={defaultBranch} |
| 1087 | base={base} |
| 1088 | now={Date.now()} |
| 1089 | limit={LIVE_SHOWN} |
| 1090 | ender={loaderData.ender} |
| 1091 | /> |
| 1092 | {moreItems > 0 && ( |
| 1093 | <Link to={sessionsPath(base.split("/")[1]!, { repo: base.split("/")[2] })} className="mt-2 inline-block px-1 text-xs text-muted hover:text-fg"> |
| 1094 | {plural(moreItems, "more work item")} being worked on |
| 1095 | </Link> |
| 1096 | )} |
| 1097 | </> |
| 1098 | ) : member ? ( |
| 1099 | <StartHere title={`Nobody is working on ${name} right now`} /> |
| 1100 | ) : ( |
| 1101 | <Quiet>Nobody is working on {name} right now. When people and their coding agents claim a work item or start a session, it shows here.</Quiet> |
| 1102 | )} |
| 1103 | </Panel> |
| 1104 | |
| 1105 | <div className="grid items-start gap-6 xl:grid-cols-2"> |
| 1106 | <DeliveryPanel loaderData={loaderData} base={base} /> |
| 1107 | <WorkflowPanel loaderData={loaderData} base={base} /> |
| 1108 | </div> |
| 1109 | </div> |
| 1110 | </div> |
| 1111 | ); |
| 1112 | } |
| 1113 | |
| 1114 | /** The last day of requests as a row of bars, an hour each, oldest on the left. */ |
| 1115 | function RequestBars({ points }: { points: TelemetryOverview["points"] }) { |
| 1116 | const most = Math.max(1, ...points.map((p) => p.requests)); |
| 1117 | return ( |
| 1118 | <div className="flex h-10 items-end gap-px" role="img" aria-label="Requests over the last day, an hour each, oldest first"> |
| 1119 | {points.map((point) => { |
| 1120 | const failed = Math.max(point.errors, point.status_5xx); |
| 1121 | const label = `${new Date(point.time_us / 1000).toLocaleTimeString("en-US", { hour: "numeric" })}: ${plural(point.requests, "request")}${failed ? `, ${failed} failed` : ""}`; |
| 1122 | return ( |
| 1123 | <Hint key={point.time_us} label={label}> |
| 1124 | <span |
| 1125 | className={`min-h-0.5 flex-1 rounded-sm ${point.requests && failed / point.requests >= 0.05 ? "bg-danger/80" : "bg-accent/60"}`} |
| 1126 | style={{ height: `${Math.max(4, (point.requests / most) * 100)}%` }} |
| 1127 | /> |
| 1128 | </Hint> |
| 1129 | ); |
| 1130 | })} |
| 1131 | </div> |
| 1132 | ); |
| 1133 | } |
| 1134 | |
| 1135 | /** Its running software's last day, at a glance: traffic, latency, and the errors still open. */ |
| 1136 | function ObservabilityCard({ overview, errors, base }: { overview: TelemetryOverview; errors: ErrorGroup[]; base: string }) { |
| 1137 | const shown = [...errors].sort((a, b) => b.last_us - a.last_us).slice(0, ERRORS_SHOWN); |
| 1138 | return ( |
| 1139 | <Card asChild className="p-5"> |
| 1140 | <section aria-labelledby="overview-observability"> |
| 1141 | <div className="flex items-center justify-between"> |
| 1142 | <h2 id="overview-observability" className="flex items-center gap-1.5 text-sm font-semibold"> |
| 1143 | <Activity size={14} className="text-faint" /> |
| 1144 | Observability |
| 1145 | </h2> |
| 1146 | <Link to={`${base}/observability`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> |
| 1147 | Last 24 hours <ArrowRight size={12} /> |
| 1148 | </Link> |
| 1149 | </div> |
| 1150 | <div className="mt-4"> |
| 1151 | <RequestBars points={overview.points} /> |
| 1152 | </div> |
| 1153 | <dl className="mt-3 grid grid-cols-2 gap-3 text-xs"> |
| 1154 | <div> |
| 1155 | <dt className="text-faint">Requests</dt> |
| 1156 | <dd className="mt-0.5 font-medium tabular-nums">{count(overview.totals.requests)}</dd> |
| 1157 | </div> |
| 1158 | <div> |
| 1159 | <dt className="text-faint">Error rate</dt> |
| 1160 | <dd className="mt-0.5 font-medium tabular-nums"> |
| 1161 | <Link to={`${base}/observability/errors`} className={overview.totals.error_rate >= 0.01 ? "text-danger" : "hover:text-accent"}> |
| 1162 | {percent(overview.totals.error_rate)} |
| 1163 | </Link> |
| 1164 | <span className="ml-1 text-muted">({count(overview.totals.errors)})</span> |
| 1165 | </dd> |
| 1166 | </div> |
| 1167 | <div> |
| 1168 | <dt className="text-faint">Latency p50</dt> |
| 1169 | <dd className="mt-0.5 font-medium tabular-nums">{duration(overview.totals.latency.p50)}</dd> |
| 1170 | </div> |
| 1171 | <div> |
| 1172 | <dt className="text-faint">Latency p95</dt> |
| 1173 | <dd className="mt-0.5 font-medium tabular-nums">{duration(overview.totals.latency.p95)}</dd> |
| 1174 | </div> |
| 1175 | </dl> |
| 1176 | <div className="mt-4 border-t border-line pt-3 text-xs"> |
| 1177 | {shown.length === 0 ? ( |
| 1178 | <p className="text-muted">No open errors.</p> |
| 1179 | ) : ( |
| 1180 | <ul className="space-y-2.5"> |
| 1181 | {shown.map((error) => ( |
| 1182 | <li key={error.fingerprint} className="min-w-0"> |
| 1183 | <Link to={`${base}/observability/errors#${error.fingerprint}`} className="block min-w-0 hover:text-accent"> |
| 1184 | <span className="block truncate font-mono text-danger">{error.name}</span> |
| 1185 | <span className="block truncate text-fg-soft">{error.message}</span> |
| 1186 | </Link> |
| 1187 | <span className="text-faint"> |
| 1188 | {plural(error.count, "time")} · last <TimeAgo at={error.last_us / 1000} /> |
| 1189 | </span> |
| 1190 | </li> |
| 1191 | ))} |
| 1192 | {errors.length > shown.length && ( |
| 1193 | <li> |
| 1194 | <Link to={`${base}/observability/errors`} className="text-muted hover:text-fg"> |
| 1195 | {plural(errors.length - shown.length, "more open error")} |
| 1196 | </Link> |
| 1197 | </li> |
| 1198 | )} |
| 1199 | </ul> |
| 1200 | )} |
| 1201 | </div> |
| 1202 | </section> |
| 1203 | </Card> |
| 1204 | ); |
| 1205 | } |
| 1206 | |
| 1207 | /** How it is keeping up: its recent builds, for members, and its open work items by age. */ |
| 1208 | function HealthCard({ loaderData, base, plan }: { loaderData: Loaded; base: string; plan: string }) { |
| 1209 | const { member, builds, open, health } = loaderData; |
| 1210 | const ages = health.ages; |
| 1211 | const oldest = ages ? Math.max(1, ...ages.map((a) => a.count)) : 1; |
| 1212 | return ( |
| 1213 | <Card asChild className="p-5"> |
| 1214 | <section aria-labelledby="overview-health"> |
| 1215 | <h2 id="overview-health" className="flex items-center gap-1.5 text-sm font-semibold"> |
| 1216 | <HeartPulse size={14} className="text-faint" /> |
| 1217 | Health |
| 1218 | </h2> |
| 1219 | <div className="mt-4 space-y-4 text-xs"> |
| 1220 | {member && (plan === "production" || builds.length > 0) && ( |
| 1221 | <div> |
| 1222 | <div className="flex items-baseline justify-between"> |
| 1223 | <span className="text-muted">Builds</span> |
| 1224 | <Link to={`${base}/deployments`} className="text-faint hover:text-fg"> |
| 1225 | {builds.length ? `last ${Math.min(builds.length, 20)}` : "none yet"} |
| 1226 | </Link> |
| 1227 | </div> |
| 1228 | <div className="mt-1.5"> |
| 1229 | <DeployStrip builds={builds} base={base} /> |
| 1230 | </div> |
| 1231 | </div> |
| 1232 | )} |
| 1233 | <div> |
| 1234 | <div className="flex items-baseline justify-between"> |
| 1235 | <span className="text-muted">Open work items by age</span> |
| 1236 | <Link to={repoWorkPath(base.split("/")[1]!, base.split("/")[2]!)} className="font-medium tabular-nums hover:text-accent"> |
| 1237 | {open.items} |
| 1238 | </Link> |
| 1239 | </div> |
| 1240 | {ages ? ( |
| 1241 | <ul className="mt-2 space-y-1.5"> |
| 1242 | {ages.map((bucket) => ( |
| 1243 | <li key={bucket.label} className="grid grid-cols-[6.5rem_1fr_1.5rem] items-center gap-2"> |
| 1244 | <span className="text-faint">{bucket.label}</span> |
| 1245 | <span className="block h-1.5 overflow-hidden rounded-full bg-line"> |
| 1246 | <span |
| 1247 | className={`block h-full rounded-full ${bucket.label === "Older" ? "bg-warn" : "bg-fg-soft/60"}`} |
| 1248 | style={{ width: `${(bucket.count / oldest) * 100}%` }} |
| 1249 | /> |
| 1250 | </span> |
| 1251 | <span className="text-right tabular-nums text-muted">{bucket.count}</span> |
| 1252 | </li> |
| 1253 | ))} |
| 1254 | </ul> |
| 1255 | ) : ( |
| 1256 | <p className="mt-1 text-faint">Work items could not be loaded just now.</p> |
| 1257 | )} |
| 1258 | </div> |
| 1259 | </div> |
| 1260 | </section> |
| 1261 | </Card> |
| 1262 | ); |
| 1263 | } |
| 1264 | |
| 1265 | /** The same date range and metrics as the project's workspace Insights. */ |
| 1266 | function DeliveryPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) { |
| 1267 | if (!d.member) return null; |
| 1268 | const metrics = d.insights?.projects[0]; |
| 1269 | const days = deliveryDays(d.range.from, d.range.until, d.insights?.days ?? []); |
| 1270 | const peak = Math.max(1, ...days.map((day) => day.merged)); |
| 1271 | return ( |
| 1272 | <Panel |
| 1273 | title="Delivery" |
| 1274 | icon={<Activity size={14} />} |
| 1275 | extra={<span className="text-xs text-muted">Last 30 days</span>} |
| 1276 | all={ |
| 1277 | d.workspaceMember |
| 1278 | ? { to: `/${base.split("/")[1]}/-/insights?period=30d&project=${encodeURIComponent(base.split("/")[2])}`, label: "Insights" } |
| 1279 | : undefined |
| 1280 | } |
| 1281 | > |
| 1282 | {!metrics ? ( |
| 1283 | <Unavailable what="Delivery insights" /> |
| 1284 | ) : ( |
| 1285 | <Card className="overflow-hidden"> |
| 1286 | <dl className="grid grid-cols-1 divide-y divide-line border-b border-line sm:grid-cols-3 sm:divide-x sm:divide-y-0"> |
| 1287 | {[ |
| 1288 | { label: "Pull requests merged", value: metrics.merged }, |
| 1289 | { label: "Average time to merge", value: deliveryDuration(metrics.merged ? metrics.leadSeconds / metrics.merged : null) }, |
| 1290 | { label: "Average first review", value: deliveryDuration(metrics.reviewed ? metrics.reviewSeconds / metrics.reviewed : null) }, |
| 1291 | ].map((stat) => ( |
| 1292 | <div key={stat.label} className="p-4"> |
| 1293 | <dt className="text-xs text-muted">{stat.label}</dt> |
| 1294 | <dd className="mt-2 text-xl font-semibold tabular-nums">{stat.value}</dd> |
| 1295 | </div> |
| 1296 | ))} |
| 1297 | </dl> |
| 1298 | <div className="p-4"> |
| 1299 | <div className="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted"> |
| 1300 | <span>Merged pull requests per day</span> |
| 1301 | <span>{rangeLabel(d.range.from, d.range.until)} · UTC</span> |
| 1302 | </div> |
| 1303 | <div className="flex h-16 items-end gap-1" role="img" aria-label={`${metrics.merged} pull requests merged in the last 30 days`}> |
| 1304 | {days.map((day) => ( |
| 1305 | <Hint key={day.day} label={`${day.day}: ${plural(day.merged, "merged pull request")}`}> |
| 1306 | <span |
| 1307 | className={`min-w-0 flex-1 rounded-sm ${day.merged ? "bg-accent/70" : "bg-line"}`} |
| 1308 | style={{ height: `${Math.max(4, (day.merged / peak) * 100)}%` }} |
| 1309 | /> |
| 1310 | </Hint> |
| 1311 | ))} |
| 1312 | </div> |
| 1313 | {metrics.merged === 0 && ( |
| 1314 | <p className="mt-3 text-xs text-faint">No pull requests merged in this period. Direct pushes appear in workflows below.</p> |
| 1315 | )} |
| 1316 | </div> |
| 1317 | <div className="flex flex-wrap gap-x-5 gap-y-2 border-t border-line px-4 py-3 text-xs text-muted"> |
| 1318 | <Link to={`${base}/pulls`} className="hover:text-fg"> |
| 1319 | {metrics.active} active pull requests |
| 1320 | </Link> |
| 1321 | <Link to={`${base}/pulls`} className="hover:text-fg"> |
| 1322 | {metrics.unreviewed} awaiting first review |
| 1323 | </Link> |
| 1324 | <Link to={`${base}/queue`} className="hover:text-fg"> |
| 1325 | {metrics.queued} in merge queue |
| 1326 | </Link> |
| 1327 | </div> |
| 1328 | </Card> |
| 1329 | )} |
| 1330 | </Panel> |
| 1331 | ); |
| 1332 | } |
| 1333 | |
| 1334 | /** The latest sample, with real times, outcomes and links to each run. */ |
| 1335 | function WorkflowPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) { |
| 1336 | const runs = d.workflowRuns; |
| 1337 | const oldest = runs?.at(-1); |
| 1338 | return ( |
| 1339 | <Panel title="Recent workflows" icon={<Activity size={14} />} all={{ to: `${base}/actions`, label: "All workflows" }}> |
| 1340 | {runs == null ? ( |
| 1341 | <Unavailable what="Workflows" /> |
| 1342 | ) : runs.length === 0 ? ( |
| 1343 | <Quiet>No workflows have run yet.</Quiet> |
| 1344 | ) : ( |
| 1345 | <Card className="overflow-hidden"> |
| 1346 | <div className="space-y-3 border-b border-line p-4"> |
| 1347 | <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted"> |
| 1348 | <span>Latest {plural(runs.length, "run")} · all branches</span> |
| 1349 | <span>{oldest && <TimeAgo at={oldest.createdAt} />} → latest</span> |
| 1350 | </div> |
| 1351 | <div className="flex gap-1" aria-label="Workflow history, oldest first"> |
| 1352 | {[...runs].reverse().map((run) => ( |
| 1353 | <Hint key={run.id} label={`${run.name}: ${standingWord(run)} · ${run.createdAt}`}> |
| 1354 | <Link |
| 1355 | to={`${base}/actions/runs/${run.id}`} |
| 1356 | aria-label={`${run.name}: ${standingWord(run)}`} |
| 1357 | className={`h-6 min-w-0 flex-1 rounded-sm transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:outline-accent ${run.status !== "completed" ? "bg-warn/70 motion-safe:animate-pulse" : run.conclusion === "success" ? "bg-success/70" : run.conclusion === "failure" ? "bg-danger/70" : "bg-line"}`} |
| 1358 | /> |
| 1359 | </Hint> |
| 1360 | ))} |
| 1361 | </div> |
| 1362 | <p className="text-xs text-muted"> |
| 1363 | {d.health.checkRuns |
| 1364 | ? `${share(d.health.passRate)} succeeded across ${d.health.checkRuns} completed runs. Cancelled and skipped runs excluded.` |
| 1365 | : "No completed successful or failed runs in this sample."} |
| 1366 | </p> |
| 1367 | </div> |
| 1368 | <ul className="divide-y divide-line"> |
| 1369 | {runs.slice(0, 5).map((run) => ( |
| 1370 | <li key={run.id}> |
| 1371 | <Link to={`${base}/actions/runs/${run.id}`} className="flex items-center gap-3 px-4 py-3 hover:bg-raised/40"> |
| 1372 | <StatusIcon status={run.status} conclusion={run.conclusion} /> |
| 1373 | <span className="min-w-0 flex-1"> |
| 1374 | <span className="block truncate text-sm font-medium">{run.title || run.name}</span> |
| 1375 | <span className="block truncate text-xs text-muted"> |
| 1376 | {run.name} · {shortRef(run.ref)} · {standingWord(run)} |
| 1377 | {run.attempt > 1 ? ` · attempt ${run.attempt}` : ""} |
| 1378 | </span> |
| 1379 | </span> |
| 1380 | <span className="shrink-0 text-right text-xs text-muted"> |
| 1381 | <TimeAgo at={run.createdAt} /> |
| 1382 | {run.startedAt && <Duration className="block" start={run.startedAt} end={run.finishedAt} />} |
| 1383 | </span> |
| 1384 | </Link> |
| 1385 | </li> |
| 1386 | ))} |
| 1387 | </ul> |
| 1388 | </Card> |
| 1389 | )} |
| 1390 | </Panel> |
| 1391 | ); |
| 1392 | } |