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,169 linesCodeBlameRaw
1import {
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";
19import { type ReactNode, Suspense } from "react";
20import { Await, Form, Link } from "react-router";
21
22import { type ErrorGroup, type PackageSummary, type Project, type TelemetryOverview, workOwner } from "@g1t/contracts";
23
24import type { Route } from "./+types/overview";
25import { DeployLink, host, StatusDot } from "../../components/deploy";
26import { useRefreshWhile } from "../../lib/refresh";
27import { DeployStrip, NeedsList, Panel, Quiet, Unavailable, percent as share } from "../../components/mission";
28import { PackageIcon } from "../../components/package-icon";
29import { Hint } from "../../components/ui/hint";
30import { Loading, Skeleton, SkeletonRows } from "../../components/ui/skeleton";
31import { ProductionShot } from "../../components/production-shot";
32import { GithubLinkStrip } from "../../components/github";
33import { githubApp } from "../../lib/github.server";
34import { ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui";
35import { Card } from "../../components/ui/card";
36import { type LivePull, LiveNowList, StartHere } from "../../components/coordination";
37import { liveByIssue } from "../../lib/coordination";
38import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission";
39import { count, duration, percent } from "../../lib/observability";
40import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages";
41import {
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";
54import { AboutEditor, KindMenu, LinkList } from "../../components/project-about";
55import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns, deploymentStatus } from "../../components/project-head";
56import { actions, deployments, events as eventLog, packages, projects, repos, telemetry, work } from "../../lib/services.server";
57import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
58import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server";
59import { shotVersion } from "./production-screenshot";
60import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel";
61import { STATE_WORD, environmentUrl, productionEnvironment, reportedProduction } from "../../lib/deployments";
62import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image";
63import { useAddresses } from "../../lib/addresses";
64import { commitChecksFor } from "../../lib/commit-checks.server";
65import { CommitChecksBadge } from "../../components/commit-checks";
66import { repoPages } from "../../lib/repo-live";
67import { useRepoLive } from "../../lib/use-repo-live";
68
69/** A library's packages shown on its overview; the rest are a link away. */
70const PACKAGES_SHOWN = 3;
71/** Issues being worked on that Live now lists; Work has the rest. */
72const LIVE_SHOWN = 4;
73/** Open errors the Observability card names. */
74const ERRORS_SHOWN = 3;
75const 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 */
88export function loader({ params, context }: Route.LoaderArgs) {
89 return { overview: overviewData({ params, context }) };
90}
91
92async 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
333export 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
352type 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 */
359function 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 */
451function 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
456export 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. */
472function 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
509function 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. */
518function 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
535function 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 */
545function 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. */
637function 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. */
669function 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
785function 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. */
980function 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. */
1001function 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. */
1060function 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. */
1118function 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}