Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,392 linesCodeBlameRaw
1import {
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";
18import { type ReactNode, Suspense } from "react";
19import { Await, Form, Link } from "react-router";
20
21import { type ErrorGroup, type PackageSummary, type Project, type TelemetryOverview, workOwner } from "@g1t/contracts";
22
23import type { Route } from "./+types/overview";
24import { DeployLink, host, StatusDot } from "../../components/deploy";
25import { useRefreshWhile } from "../../lib/refresh";
26import { DeployStrip, NeedsList, Panel, Quiet, Unavailable, percent as share } from "../../components/mission";
27import { PackageIcon } from "../../components/package-icon";
28import { Hint } from "../../components/ui/hint";
29import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
30import { ProductionShot } from "../../components/production-shot";
31import { GithubLinkStrip } from "../../components/github";
32import { githubApp } from "../../lib/github.server";
33import { ButtonLink, CopyLine, SoonPill, SubmitButton, TimeAgo } from "../../components/ui";
34import { Card } from "../../components/ui/card";
35import { Button } from "../../components/ui/button";
36import { type LivePull, LiveNowList, StartHere } from "../../components/coordination";
37import { awakeAndIdle, liveByItem } from "../../lib/coordination";
38import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission";
39import { repoWorkPath, sessionsPath } from "../../lib/work-items";
40import { count, duration, percent } from "../../lib/observability";
41import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
42import {
43 PUBLISH_GUIDES,
44 kindLabel,
45 latestTag,
46 libraryPackages,
47 packageName,
48 packagePath,
49 projectChanges,
50 projectPlan,
51 publishGuide,
52} from "../../lib/project-kind";
53import { KindMenu } from "../../components/project-about";
54import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns, deploymentStatus } from "../../components/project-head";
55import { actions, deployments, packages, projects, repos, telemetry, work } from "../../lib/services.server";
56import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
57import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server";
58import { shotVersion } from "./production-screenshot";
59import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel";
60import { STATE_WORD, nativeProduction, productionEnvironment, reportedProduction } from "../../lib/deployments";
61import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image";
62import { useAddresses } from "../../lib/addresses";
63import { commitChecksFor } from "../../lib/commit-checks.server";
64import { CommitChecksSummary } from "../../components/commit-checks";
65import { projectCheckSections, hasSeparateAttempt } from "../../lib/project-checks";
66import { StatusIcon, Duration, shortRef, standingWord } from "../../components/actions";
67import { resolveRange, rangeLabel } from "../../lib/usage";
68import { deliveryDays, duration as deliveryDuration } from "../../lib/workspace-insights";
69import { repoPages } from "../../lib/repo-live";
70import { useRepoLive } from "../../lib/use-repo-live";
71
72/** A library's packages shown on its overview; the rest are a link away. */
73const PACKAGES_SHOWN = 3;
74/** Work items being worked on that Live now lists; Sessions has the rest. */
75const LIVE_SHOWN = 4;
76/** Open errors the Observability card names. */
77const ERRORS_SHOWN = 3;
78const 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 */
91export function loader({ params, context }: Route.LoaderArgs) {
92 return { overview: overviewData({ params, context }) };
93}
94
95async 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
368export 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
387type 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 */
394function 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 */
486function 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
491export 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. */
507function 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
530function 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
538function 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. */
543function 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. */
571function 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. */
661function 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. */
791function 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. */
842function 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
884function 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. */
1115function 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. */
1136function 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. */
1208function 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. */
1266function 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. */
1335function 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}