| 2 | 2 | | Activity, |
| 3 | 3 | | ArrowRight, |
| 4 | 4 | | ArrowUpRight, |
| 5 | | − | BookOpen, |
| 6 | 5 | | Code2, |
| 7 | 6 | | GitBranch, |
| 8 | 7 | | GitCommitHorizontal, |
| 9 | | − | Globe, |
| 10 | 8 | | Hand, |
| 11 | 9 | | HeartPulse, |
| 12 | 10 | | Lock, |
| ⋯ |
| 14 | 12 | | Radio, |
| 15 | 13 | | Rocket, |
| 16 | 14 | | RotateCw, |
| 17 | | − | Tag, |
| 18 | 15 | | } from "lucide-react"; |
| 19 | 16 | | import { type ReactNode, Suspense } from "react"; |
| 20 | 17 | | import { Await, Form, Link } from "react-router"; |
| ⋯ |
| 35 | 32 | | import { Card } from "../../components/ui/card"; |
| 36 | 33 | | import { type LivePull, LiveNowList, StartHere } from "../../components/coordination"; |
| 37 | 34 | | import { awakeAndIdle, liveByIssue } from "../../lib/coordination"; |
| 38 | | − | import { type Need, ageBuckets, firstPassRate, passRate, rankNeeds, runHealth } from "../../lib/mission"; |
| 35 | + | import { type Need, ageBuckets, rankNeeds, runHealth } from "../../lib/mission"; |
| 39 | 36 | | import { count, duration, percent } from "../../lib/observability"; |
| 40 | 37 | | import { ECOSYSTEM_LABEL, installCommands } from "../../lib/packages"; |
| 41 | 38 | | import { |
| 42 | 39 | | PUBLISH_GUIDES, |
| 43 | | − | bare, |
| 44 | 40 | | kindLabel, |
| 45 | | − | linksToShow, |
| 46 | 41 | | latestTag, |
| 47 | 42 | | libraryPackages, |
| 48 | 43 | | packageName, |
| ⋯ |
| 51 | 46 | | projectPlan, |
| 52 | 47 | | publishGuide, |
| 53 | 48 | | } from "../../lib/project-kind"; |
| 54 | | − | import { AboutEditor, KindMenu, LinkList } from "../../components/project-about"; |
| 49 | + | import { KindMenu } from "../../components/project-about"; |
| 55 | 50 | | import { DocsHead, ElsewhereHead, type ExternalDeployment, OtherHead, WhereItRuns, deploymentStatus } from "../../components/project-head"; |
| 56 | | − | import { actions, deployments, events as eventLog, packages, projects, repos, telemetry, work } from "../../lib/services.server"; |
| 57 | | − | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; |
| 51 | + | import { actions, deployments, packages, projects, repos, telemetry, work } from "../../lib/services.server"; |
| 52 | + | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; |
| 58 | 53 | | import { accessTo, countsFor, projectFor, refusal, repoFor } from "../../lib/access.server"; |
| 59 | 54 | | import { shotVersion } from "./production-screenshot"; |
| 60 | 55 | | import { DeploymentStateIcon, DeploymentsPanel } from "../../components/deployments-panel"; |
| ⋯ |
| 62 | 57 | | import { cardSources, externalTarget, externalVersion, productionImageUrl } from "../../lib/production-image"; |
| 63 | 58 | | import { useAddresses } from "../../lib/addresses"; |
| 64 | 59 | | import { commitChecksFor } from "../../lib/commit-checks.server"; |
| 65 | | − | import { CommitChecksBadge } from "../../components/commit-checks"; |
| 60 | + | import { CommitChecksBadge, CommitChecksSummary } from "../../components/commit-checks"; |
| 61 | + | import { StatusIcon, Duration, shortRef, standingWord } from "../../components/actions"; |
| 62 | + | import { resolveRange, rangeLabel } from "../../lib/usage"; |
| 63 | + | import { deliveryDays, duration as deliveryDuration } from "../../lib/workspace-insights"; |
| 66 | 64 | | import { repoPages } from "../../lib/repo-live"; |
| 67 | 65 | | import { useRepoLive } from "../../lib/use-repo-live"; |
| 68 | 66 | | |
| ⋯ |
| 115 | 113 | | // A library or a tool shows its packages where an app shows production. |
| 116 | 114 | | const libraryRepoP = Promise.all([planP, repoP]).then(([plan, repo]) => (plan === "release" && repo?.ok ? repo.value : null)); |
| 117 | 115 | | 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 | 116 | | const workflowRunsP = soft(actions.runs(path, viewer, { limit: 30 })); |
| 124 | 117 | | // What waits on someone signed in: their pull requests' lifecycles, and the open ones. |
| 125 | 118 | | const minePullsP = repoP.then((repo) => |
| ⋯ |
| 147 | 140 | | : null, |
| 148 | 141 | | ) |
| 149 | 142 | | : 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 | | − | ); |
| 143 | + | const commitP = soft(repos.log(path, viewer, null, 1)).then((log) => log?.ok ? (log.value[0] ?? null) : null); |
| 144 | + | const range = resolveRange("30d", {}, new Date(now)); |
| 145 | + | const insightsP = repoP.then((repo) => repo?.ok |
| 146 | + | ? forMembers(() => work.deliveryInsights([repo.value.id], viewer, range.from, range.until)) |
| 147 | + | : null); |
| 157 | 148 | | |
| 158 | 149 | | const [ |
| 159 | 150 | | { insider: member, can }, |
| ⋯ |
| 169 | 160 | | mine, |
| 170 | 161 | | packageList, |
| 171 | 162 | | tagList, |
| 172 | | − | checkEvents, |
| 163 | + | insights, |
| 173 | 164 | | workflowRuns, |
| 174 | 165 | | observed, |
| 175 | 166 | | commit, |
| ⋯ |
| 191 | 182 | | packagesP, |
| 192 | 183 | | // The latest release, for About. |
| 193 | 184 | | soft(repos.tags(path, viewer)), |
| 194 | | − | checkEventsP, |
| 185 | + | insightsP, |
| 195 | 186 | | workflowRunsP, |
| 196 | 187 | | telemetryP, |
| 197 | 188 | | commitP, |
| ⋯ |
| 242 | 233 | | } |
| 243 | 234 | | |
| 244 | 235 | | // --- 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 | 236 | | const openIssues = ok(issues); |
| 249 | 237 | | |
| 250 | 238 | | // --- Live now ------------------------------------------------------------------ |
| ⋯ |
| 284 | 272 | | |
| 285 | 273 | | return { |
| 286 | 274 | | member, |
| 275 | + | workspaceMember: roleIn(viewer, params.owner) != null, |
| 287 | 276 | | can, |
| 288 | 277 | | github, |
| 289 | 278 | | project: projectValue, |
| ⋯ |
| 291 | 280 | | builds: builds.slice(0, 30), |
| 292 | 281 | | previews: live.filter((app) => app.kind === "preview"), |
| 293 | 282 | | production, |
| 283 | + | serving: current ? { sha: current.sha, at: current.updated_at } : null, |
| 294 | 284 | | // The screenshot of production on g1t.page, for members (like its builds). |
| 295 | 285 | | productionShot: member && liveProduction ? shotVersion(liveProduction) : null, |
| 296 | 286 | | latestProductionBuild: latestProduction ? { status: latestProduction.status } : null, |
| 297 | 287 | | productionLatest: productionLatest |
| 298 | | − | ? { id: productionLatest.id, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name } |
| 288 | + | ? { id: productionLatest.id, sha: productionLatest.sha, state: productionLatest.state, at: productionLatest.updated_at, environment: productionEnv!.name } |
| 299 | 289 | | : null, |
| 300 | 290 | | commit: commit ? { hash: commit.hash, authoredAt: commit.authoredAt } : null, |
| 301 | 291 | | // The commit's checks, streamed in after the page. |
| 302 | | − | checks: commit ? commitChecksFor(path, viewer, [commit.hash]) : Promise.resolve(null), |
| 292 | + | checks: commitChecksFor(path, viewer, [commit?.hash, deployment?.sha, productionLatest?.sha]), |
| 293 | + | workflowRuns: ok(workflowRuns), |
| 294 | + | insights, |
| 295 | + | range, |
| 303 | 296 | | release: latestTag(ok(tagList) ?? []), |
| 304 | 297 | | // Production as reported from outside g1t, for an app deployed elsewhere. |
| 305 | 298 | | deployment, |
| ⋯ |
| 324 | 317 | | livePulls, |
| 325 | 318 | | defaultBranch, |
| 326 | 319 | | health: { |
| 327 | | − | // A pull request's checks when any finished lately; otherwise the |
| 328 | | − | // project's workflow runs, for one that ships straight to its branch. |
| 329 | | − | ...(checkResults.length > 0 || !workflowRuns?.ok |
| 330 | | − | ? { passRate: passRate(checkResults.map((c) => c.passed)), firstPass: firstPassRate(checkResults), checkRuns: checkResults.length } |
| 331 | | − | : runHealth(workflowRuns.value)), |
| 320 | + | ...runHealth(ok(workflowRuns) ?? []), |
| 332 | 321 | | ages: openIssues ? ageBuckets(openIssues.map((issue) => issue.createdAt), now) : null, |
| 333 | 322 | | }, |
| 334 | 323 | | telemetry: telemetryData ? { overview: telemetryData.overview!, errors: telemetryData.errors ?? [] } : null, |
| ⋯ |
| 551 | 540 | | * instead, so nothing is said twice. |
| 552 | 541 | | */ |
| 553 | 542 | | function Facts({ loaderData, base, plan }: { loaderData: Loaded; base: string; plan: string }) { |
| 554 | | − | const { productionLatest, release, library, telemetry, health, open, environments } = loaderData; |
| 543 | + | const { productionLatest, release, library, telemetry, open, environments } = loaderData; |
| 555 | 544 | | const headShowsProduction = !library && (plan === "production" || plan === "elsewhere"); |
| 556 | 545 | | const releaseFact = release ? ( |
| 557 | 546 | | <Fact |
| ⋯ |
| 592 | 581 | | ) : ( |
| 593 | 582 | | <Fact label="Latest release" to={`${base}/tags`} value={<span className="font-normal text-muted">None yet</span>} /> |
| 594 | 583 | | ); |
| 595 | | − | const checks = ( |
| 596 | | − | <Fact |
| 597 | | − | label="Checks passing" |
| 598 | | − | to={`${base}/actions`} |
| 599 | | − | hint={health.checkRuns ? `Of the last ${plural(health.checkRuns, "run")}` : "No checks have run recently"} |
| 600 | | − | value={ |
| 601 | | − | health.checkRuns ? ( |
| 602 | | − | <span className={health.passRate != null && health.passRate < 0.7 ? "text-warn" : undefined}>{share(health.passRate)}</span> |
| 603 | | − | ) : ( |
| 604 | | − | <span className="font-normal text-muted">None lately</span> |
| 605 | | − | ) |
| 606 | | − | } |
| 607 | | − | /> |
| 608 | | − | ); |
| 584 | + | const branch = <Fact label="Default branch" to={`${base}/code`} value={loaderData.defaultBranch ?? "Unavailable"} />; |
| 609 | 585 | | return ( |
| 610 | 586 | | <div className="grid grid-cols-2 divide-line border-t border-line text-sm sm:grid-cols-4 sm:divide-x"> |
| 611 | 587 | | {first} |
| ⋯ |
| 623 | 599 | | </span> |
| 624 | 600 | | } |
| 625 | 601 | | /> |
| 626 | | − | {checks} |
| 602 | + | {branch} |
| 627 | 603 | | </> |
| 628 | 604 | | ) : ( |
| 629 | 605 | | <> |
| 630 | | − | {checks} |
| 631 | | − | <Fact |
| 632 | | − | label="Pass on the first try" |
| 633 | | − | to={`${base}/actions`} |
| 634 | | − | hint={health.firstPass.of ? `Of ${plural(health.firstPass.of, "change")} checked lately` : undefined} |
| 635 | | − | value={health.firstPass.of ? share(health.firstPass.rate) : <span className="font-normal text-muted">—</span>} |
| 636 | | − | /> |
| 637 | | − | <Fact label="Open" to={`${base}/issues`} value={`${plural(open.issues, "issue")} · ${plural(open.pulls, "pull request")}`} /> |
| 606 | + | {branch} |
| 607 | + | <Fact label="Open issues" to={`${base}/issues`} value={open.issues} /> |
| 608 | + | <Fact label="Open pull requests" to={`${base}/pulls`} value={open.pulls} /> |
| 638 | 609 | | </> |
| 639 | 610 | | )} |
| 640 | 611 | | </div> |
| ⋯ |
| 799 | 770 | | actionData: Route.ComponentProps["actionData"]; |
| 800 | 771 | | params: Route.ComponentProps["params"]; |
| 801 | 772 | | }) { |
| 802 | | − | const { member, project, settings, production, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData; |
| 773 | + | const { member, project, needs, library, claims, sessions, titles, livePulls, defaultBranch, previews } = loaderData; |
| 803 | 774 | | const base = `/${params.owner}/${params.repo}`; |
| 804 | 775 | | const name = project?.name ?? params.repo; |
| 805 | 776 | | const plan = project ? projectPlan(project) : "production"; |
| 806 | 777 | | const canChange = member && loaderData.can.manage_settings; |
| 807 | | − | const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null; |
| 808 | | − | // Its homepage and docs beside what it is, unless the card below already links there. |
| 809 | | − | const headUrl = |
| 810 | | − | plan === "production" |
| 811 | | − | ? (productionUrl ?? settings?.productionUrl) |
| 812 | | − | : plan === "elsewhere" |
| 813 | | − | ? (loaderData.deployment?.url ?? project?.productionUrl) |
| 814 | | − | : plan === "docs" |
| 815 | | − | ? (project?.links.docs ?? project?.productionUrl ?? project?.links.homepage) |
| 816 | | − | : plan === "other" |
| 817 | | − | ? (project?.links.homepage ?? project?.links.docs) |
| 818 | | − | : null; |
| 819 | | − | const stripLinks = project ? linksToShow({ ...project.links, custom: [] }, [headUrl]) : []; |
| 778 | + | const checkSha = loaderData.productionLatest?.sha ?? loaderData.commit?.hash; |
| 820 | 779 | | const addresses = useAddresses(); |
| 821 | 780 | | // The production card's picture: an uploaded image, served from the |
| 822 | 781 | | // usercontent origin, and where someone who may change it adds one. |
| ⋯ |
| 830 | 789 | | const busy = claims.length + awake.length > 0; |
| 831 | 790 | | const working = busy || idle.length > 0; |
| 832 | 791 | | // Deployments, checks, releases, claims and sessions show as they happen. |
| 833 | | − | useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) }); |
| 792 | + | useRepoLive({ match: (change) => repoPages.overview()(change) || repoPages.runs()(change) || change.kind === "git.push" }); |
| 834 | 793 | | // Claims lapse and sessions go idle with no event to say so. |
| 835 | | − | useRefreshWhile(working, 15_000); |
| 794 | + | useRefreshWhile(working || (loaderData.workflowRuns ?? []).some((run) => run.status !== "completed"), 15_000); |
| 836 | 795 | | const live = liveByIssue(claims, awake); |
| 837 | 796 | | const moreIssues = live.issues.length - LIVE_SHOWN; |
| 838 | 797 | | |
| ⋯ |
| 845 | 804 | | {project && ( |
| 846 | 805 | | <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"> |
| 847 | 806 | | <KindMenu project={project} label={kindLabel(project)} canChange={canChange} /> |
| 848 | | − | {stripLinks.map((link) => ( |
| 849 | | − | <DeployLink key={link.key} href={link.url} className="inline-flex min-w-0 items-center gap-1.5 text-muted hover:text-fg"> |
| 850 | | − | {link.type === "docs" ? <BookOpen size={12} className="shrink-0 text-faint" /> : <Globe size={12} className="shrink-0 text-faint" />} |
| 851 | | − | <span className="truncate">{link.type === "docs" ? `Docs · ${bare(link.url)}` : link.label}</span> |
| 852 | | − | </DeployLink> |
| 853 | | − | ))} |
| 807 | + | |
| 854 | 808 | | </div> |
| 855 | 809 | | )} |
| 856 | 810 | | {plan === "elsewhere" && project ? ( |
| ⋯ |
| 861 | 815 | | canDeploy={member && loaderData.can.manage_integrations} |
| 862 | 816 | | deployment={loaderData.deployment} |
| 863 | 817 | | commit={loaderData.commit} |
| 864 | | − | checks={loaderData.commit ? <CommitChecksBadge checks={loaderData.checks} sha={loaderData.commit.hash} className="size-5" /> : undefined} |
| 818 | + | checks={(loaderData.deployment?.sha ?? loaderData.commit?.hash) ? <CommitChecksBadge checks={loaderData.checks} sha={(loaderData.deployment?.sha ?? loaderData.commit?.hash)!} /> : undefined} |
| 865 | 819 | | shot={ |
| 866 | 820 | | <ProductionShot |
| 867 | 821 | | sources={cardSources(loaderData.elsewhereShot, upload)} |
| ⋯ |
| 883 | 837 | | ) : ( |
| 884 | 838 | | <ProductionHead loaderData={loaderData} base={base} name={name} upload={upload} addImage={addImage} /> |
| 885 | 839 | | )} |
| 840 | + | {checkSha && ( |
| 841 | + | <div className="border-t border-line px-5 py-4 sm:px-6"> |
| 842 | + | <div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted"> |
| 843 | + | <GitCommitHorizontal size={14} /> |
| 844 | + | <span>{loaderData.productionLatest ? "Latest production deployment" : `Latest on ${defaultBranch ?? "default branch"}`}</span> |
| 845 | + | <Link to={`${base}/commit/${checkSha}`} className="font-mono hover:text-fg">{checkSha.slice(0, 7)}</Link> |
| 846 | + | </div> |
| 847 | + | <CommitChecksSummary checks={loaderData.checks} sha={checkSha} /> |
| 848 | + | {loaderData.serving && loaderData.serving.sha !== checkSha && ( |
| 849 | + | <p className="mt-2 text-xs text-muted">Currently serving <Link to={`${base}/commit/${loaderData.serving.sha}`} className="font-mono text-fg-soft hover:underline">{loaderData.serving.sha.slice(0, 7)}</Link> · deployed <TimeAgo at={loaderData.serving.at} /></p> |
| 850 | + | )} |
| 851 | + | </div> |
| 852 | + | )} |
| 886 | 853 | | {actionData && ( |
| 887 | 854 | | <p className={`border-t border-line px-6 py-2.5 text-sm ${"error" in actionData ? "text-danger" : "text-success"}`}> |
| 888 | 855 | | {"error" in actionData ? actionData.error : actionData.notice} |
| ⋯ |
| 949 | 916 | | )} |
| 950 | 917 | | </Panel> |
| 951 | 918 | | |
| 919 | + | <DeliveryPanel loaderData={loaderData} base={base} /> |
| 920 | + | <WorkflowPanel loaderData={loaderData} base={base} /> |
| 921 | + | |
| 952 | 922 | | {previews.length > 0 && ( |
| 953 | 923 | | <Panel title="Previews" icon={<GitBranch size={14} />} count={previews.length} all={{ to: `${base}/deployments`, label: "Deployments" }}> |
| 954 | 924 | | <Card asChild divided className="overflow-hidden"> |
| ⋯ |
| 991 | 961 | | )} |
| 992 | 962 | | {!loaderData.telemetryLoaded && <Unavailable what="Observability" compact />} |
| 993 | 963 | | {loaderData.telemetry && <ObservabilityCard overview={loaderData.telemetry.overview} errors={loaderData.telemetry.errors} base={base} />} |
| 994 | | − | {project && <AboutCard project={project} canChange={canChange} base={base} release={loaderData.release} />} |
| 964 | + | |
| 995 | 965 | | <HealthCard loaderData={loaderData} base={base} plan={plan} /> |
| 996 | 966 | | </aside> |
| 997 | 967 | | </div> |
| ⋯ |
| 1137 | 1107 | | ); |
| 1138 | 1108 | | } |
| 1139 | 1109 | | |
| 1140 | | − | /** What the project is: its description, links, kind and latest release. */ |
| 1141 | | − | function AboutCard({ |
| 1142 | | − | project, |
| 1143 | | − | canChange, |
| 1144 | | − | base, |
| 1145 | | − | release, |
| 1146 | | − | }: { |
| 1147 | | − | project: NonNullable<Loaded["project"]>; |
| 1148 | | − | canChange: boolean; |
| 1149 | | − | base: string; |
| 1150 | | − | release: Loaded["release"]; |
| 1151 | | − | }) { |
| 1110 | + | /** The same date range and metrics as the project's workspace Insights. */ |
| 1111 | + | function DeliveryPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) { |
| 1112 | + | if (!d.member) return null; |
| 1113 | + | const metrics = d.insights?.projects[0]; |
| 1114 | + | const days = deliveryDays(d.range.from, d.range.until, d.insights?.days ?? []); |
| 1115 | + | const peak = Math.max(1, ...days.map((day) => day.merged)); |
| 1116 | + | return ( |
| 1117 | + | <Panel title="Delivery" icon={<Activity size={14} />} extra={<span className="text-xs text-muted">Last 30 days</span>} |
| 1118 | + | all={d.workspaceMember ? { to: `/${base.split("/")[1]}/-/insights?period=30d&project=${encodeURIComponent(base.split("/")[2])}`, label: "Insights" } : undefined}> |
| 1119 | + | {!metrics ? <Unavailable what="Delivery insights" /> : ( |
| 1120 | + | <Card className="overflow-hidden"> |
| 1121 | + | <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"> |
| 1122 | + | {[ |
| 1123 | + | { label: "Pull requests merged", value: metrics.merged }, |
| 1124 | + | { label: "Average time to merge", value: deliveryDuration(metrics.merged ? metrics.leadSeconds / metrics.merged : null) }, |
| 1125 | + | { label: "Average first review", value: deliveryDuration(metrics.reviewed ? metrics.reviewSeconds / metrics.reviewed : null) }, |
| 1126 | + | ].map((stat) => <div key={stat.label} className="p-4"><dt className="text-xs text-muted">{stat.label}</dt><dd className="mt-2 text-xl font-semibold tabular-nums">{stat.value}</dd></div>)} |
| 1127 | + | </dl> |
| 1128 | + | <div className="p-4"> |
| 1129 | + | <div className="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-muted"><span>Merged pull requests per day</span><span>{rangeLabel(d.range.from, d.range.until)} · UTC</span></div> |
| 1130 | + | <div className="flex h-16 items-end gap-1" role="img" aria-label={`${metrics.merged} pull requests merged in the last 30 days`}> |
| 1131 | + | {days.map((day) => <Hint key={day.day} label={`${day.day}: ${plural(day.merged, "merged pull request")}`}><span className={`min-w-0 flex-1 rounded-sm ${day.merged ? "bg-accent/70" : "bg-line"}`} style={{ height: `${Math.max(4, day.merged / peak * 100)}%` }} /></Hint>)} |
| 1132 | + | </div> |
| 1133 | + | {metrics.merged === 0 && <p className="mt-3 text-xs text-faint">No pull requests merged in this period. Direct pushes appear in workflows below.</p>} |
| 1134 | + | </div> |
| 1135 | + | <div className="flex flex-wrap gap-x-5 gap-y-2 border-t border-line px-4 py-3 text-xs text-muted"> |
| 1136 | + | <Link to={`${base}/pulls`} className="hover:text-fg">{metrics.active} active pull requests</Link> |
| 1137 | + | <Link to={`${base}/pulls`} className="hover:text-fg">{metrics.unreviewed} awaiting first review</Link> |
| 1138 | + | <Link to={`${base}/queue`} className="hover:text-fg">{metrics.queued} in merge queue</Link> |
| 1139 | + | </div> |
| 1140 | + | </Card> |
| 1141 | + | )} |
| 1142 | + | </Panel> |
| 1143 | + | ); |
| 1144 | + | } |
| 1145 | + | |
| 1146 | + | /** The latest sample, with real times, outcomes and links to each run. */ |
| 1147 | + | function WorkflowPanel({ loaderData: d, base }: { loaderData: Loaded; base: string }) { |
| 1148 | + | const runs = d.workflowRuns; |
| 1149 | + | const oldest = runs?.at(-1); |
| 1152 | 1150 | | return ( |
| 1153 | | − | <Card asChild className="p-5"> |
| 1154 | | − | <section> |
| 1155 | | − | <div className="flex items-center justify-between"> |
| 1156 | | − | <h2 className="text-sm font-semibold">About</h2> |
| 1157 | | − | {canChange && <AboutEditor project={project} />} |
| 1158 | | − | </div> |
| 1159 | | − | <p className={`mt-2.5 text-sm ${project.description ? "text-fg-soft" : "text-faint"}`}>{project.description ?? "No description."}</p> |
| 1160 | | − | <LinkList links={project.links} className="mt-3" /> |
| 1161 | | − | <ul className="mt-4 space-y-2 text-xs text-muted"> |
| 1162 | | − | <li className="flex items-center gap-2"> |
| 1163 | | − | {project.kind === "library" || project.kind === "tool" ? ( |
| 1164 | | − | <PackageGlyph size={13} className="shrink-0 text-faint" /> |
| 1165 | | − | ) : project.kind === "docs" ? ( |
| 1166 | | − | <BookOpen size={13} className="shrink-0 text-faint" /> |
| 1167 | | − | ) : ( |
| 1168 | | − | <Rocket size={13} className="shrink-0 text-faint" /> |
| 1169 | | − | )} |
| 1170 | | − | <Hint label={project.kindReason.detail}> |
| 1171 | | − | <span>{kindLabel(project)}</span> |
| 1172 | | − | </Hint> |
| 1173 | | − | </li> |
| 1174 | | − | {release && ( |
| 1175 | | − | <li className="flex items-center gap-2"> |
| 1176 | | − | <Tag size={13} className="shrink-0 text-faint" /> |
| 1177 | | − | <Link to={`${base}/tags`} className="hover:text-fg"> |
| 1178 | | − | <span className="font-mono text-fg-soft">{release.name}</span> |
| 1179 | | − | {release.at && ( |
| 1180 | | − | <span className="text-faint"> |
| 1181 | | − | {" · "} |
| 1182 | | − | <TimeAgo at={release.at} /> |
| 1183 | | − | </span> |
| 1184 | | − | )} |
| 1151 | + | <Panel title="Recent workflows" icon={<Activity size={14} />} all={{ to: `${base}/actions`, label: "All workflows" }}> |
| 1152 | + | {runs == null ? <Unavailable what="Workflows" /> : runs.length === 0 ? <Quiet>No workflows have run yet.</Quiet> : ( |
| 1153 | + | <Card className="overflow-hidden"> |
| 1154 | + | <div className="space-y-3 border-b border-line p-4"> |
| 1155 | + | <div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted"> |
| 1156 | + | <span>Latest {plural(runs.length, "run")} · all branches</span> |
| 1157 | + | <span>{oldest && <TimeAgo at={oldest.createdAt} />} → latest</span> |
| 1158 | + | </div> |
| 1159 | + | <div className="flex gap-1" aria-label="Workflow history, oldest first"> |
| 1160 | + | {[...runs].reverse().map((run) => <Hint key={run.id} label={`${run.name}: ${standingWord(run)} · ${run.createdAt}`}> |
| 1161 | + | <Link to={`${base}/actions/runs/${run.id}`} aria-label={`${run.name}: ${standingWord(run)}`} 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"}`} /> |
| 1162 | + | </Hint>)} |
| 1163 | + | </div> |
| 1164 | + | <p className="text-xs text-muted">{d.health.checkRuns ? `${share(d.health.passRate)} succeeded across ${d.health.checkRuns} completed runs. Cancelled and skipped runs excluded.` : "No completed successful or failed runs in this sample."}</p> |
| 1165 | + | </div> |
| 1166 | + | <ul className="divide-y divide-line"> |
| 1167 | + | {runs.slice(0, 5).map((run) => <li key={run.id}> |
| 1168 | + | <Link to={`${base}/actions/runs/${run.id}`} className="flex items-center gap-3 px-4 py-3 hover:bg-raised/40"> |
| 1169 | + | <StatusIcon status={run.status} conclusion={run.conclusion} /> |
| 1170 | + | <span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{run.title || run.name}</span><span className="block truncate text-xs text-muted">{run.name} · {shortRef(run.ref)} · {standingWord(run)}{run.attempt > 1 ? ` · attempt ${run.attempt}` : ""}</span></span> |
| 1171 | + | <span className="shrink-0 text-right text-xs text-muted"><TimeAgo at={run.createdAt} />{run.startedAt && <Duration className="block" start={run.startedAt} end={run.finishedAt} />}</span> |
| 1185 | 1172 | | </Link> |
| 1186 | | − | </li> |
| 1187 | | − | )} |
| 1188 | | − | </ul> |
| 1189 | | − | </section> |
| 1190 | | − | </Card> |
| 1173 | + | </li>)} |
| 1174 | + | </ul> |
| 1175 | + | </Card> |
| 1176 | + | )} |
| 1177 | + | </Panel> |
| 1191 | 1178 | | ); |
| 1192 | 1179 | | } |