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