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.

911 linesCodeBlameRaw
1import {
2 ArrowUpRight,
3 ChevronLeft,
4 ChevronRight,
5 ExternalLink,
6 GitBranch,
7 GitCommitHorizontal,
8 Globe,
9 Rocket,
10 RotateCw,
11 Terminal,
12 Trash2,
13} from "lucide-react";
14import type { ReactNode } from "react";
15import { Form, Link } from "react-router";
16
17import type { DeploymentEnvironment, FeatureState, RepoDeployment } from "@g1t/contracts";
18
19import { DeployLink, host } from "../../components/deploy";
20import { DeploymentStateBadge, DeploymentStateIcon, sourceLabel } from "../../components/deployments-panel";
21import { FilterMenu } from "../../components/labels";
22import { Boundary, Unavailable } from "../../components/boundary";
23
24import type { Route } from "./+types/deployments";
25import { page } from "../../lib/meta";
26import { ButtonLink, ComputeNote, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
27import { Badge } from "../../components/ui/badge";
28import { Card } from "../../components/ui/card";
29import { Hint } from "../../components/ui/hint";
30import { computeNoteFor } from "../../lib/compute.server";
31import { type Failed, isFailed, orElse, part } from "../../lib/partial";
32import { neverDeploys } from "../../lib/project-kind";
33import { billing, deployments, projects } from "../../lib/services.server";
34import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
35import { refusal, requireRepo } from "../../lib/access.server";
36import { whyNot } from "../../lib/access";
37import {
38 type ListFilter,
39 SOURCES,
40 STATES,
41 STATE_WORD,
42 SOURCE_LABEL,
43 choices,
44 environmentLabel,
45 environmentUrl,
46 isFiltered,
47 orderEnvironments,
48 pageRange,
49 parseFilter,
50 shortSha,
51 withFilter,
52} from "../../lib/deployments";
53
54const API_GUIDE = "https://docs.g1t.sh/guides/deployments-api/";
55
56export function meta({ params, ...args }: Route.MetaArgs) {
57 return page(args, { title: `Deployments · ${params.owner}/${params.repo} · g1t` });
58}
59
60export async function loader({ params, context, request }: Route.LoaderArgs) {
61 const viewer = getViewer(context);
62 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
63 const { access, repo } = await requireRepo(context, params, "read");
64 const path = { namespace: repo.namespace, name: repo.name };
65 const ref = { workspace: params.owner, slug: params.repo };
66 const filter = parseFilter(new URL(request.url).searchParams);
67 // Each part loads on its own, and one whose service fails says so in its
68 // place while the rest of the page stays up.
69 // g1t.page's settings and builds are the workspace's members' alone.
70 const insider = access.insider;
71 const [environments, list, settings, builds, features, computeNote, project] = await Promise.all([
72 part(deployments.environments(path, viewer), "Environments"),
73 part(deployments.repoDeployments(path, viewer, filter), "Deployment history"),
74 insider ? part(deployments.settings(ref, viewer), "g1t.page hosting") : null,
75 insider ? part(deployments.list(ref, viewer), "g1t.page hosting") : null,
76 insider ? part(billing.features(params.owner, viewer), "The plan") : null,
77 // Builds are compute: said before a deploy is refused for it.
78 insider ? part(computeNoteFor(params.owner, "deploy"), "Compute") : null,
79 part(projects.get(params.owner, params.repo, viewer), "The project"),
80 ]);
81 // Set not to deploy, in General settings: turning them on waits for that
82 // to change. Unread, it is left out: the deployments service refuses to
83 // turn on what the project says never deploys.
84 const known = orElse(project, null);
85 const notDeploying = known?.ok ? neverDeploys(known.value) : false;
86 // Deployments come with the g1t plan. Someone outside the workspace does
87 // not see its plans; the page works without (as it does when the plans
88 // cannot be read), and the deployments service refuses a deploy the plan
89 // does not cover.
90 const plans = orElse(features, null);
91 const plan = plans?.ok
92 ? (plans.value.find((state) => state.plan.feature === "plan" || state.plan.feature === "deployments") ?? null)
93 : null;
94 // g1t.page hosting, for the workspace's members: null when it is not
95 // theirs to see, Failed when it could not be read.
96 const pages = isFailed(settings)
97 ? settings
98 : isFailed(builds)
99 ? builds
100 : settings?.ok && builds?.ok
101 ? // A note that cannot be read is left out, as on Workflows: the refusal says the same.
102 { settings: settings.value, live: builds.value.live, plan, computeNote: orElse(computeNote, null), notDeploying }
103 : null;
104 return {
105 can: access.can,
106 filter,
107 environments: isFailed(environments)
108 ? environments
109 : environments.ok
110 ? { ...environments.value, environments: orderEnvironments(environments.value.environments) }
111 : null,
112 list: isFailed(list) ? list : list.ok ? list.value : null,
113 pages,
114 };
115}
116
117export async function action({ request, params, context }: Route.ActionArgs) {
118 assertSameOrigin(request);
119 const user = requireUser(context, request);
120 const form = await request.formData();
121 const ref = { workspace: params.owner, slug: params.repo };
122 const intent = form.get("intent");
123 const branch = form.get("branch") ? String(form.get("branch")) : null;
124 // Deploying is compute (Write); turning deployments on or off is a deployment setting (Admin).
125 const refused = await refusal(context, params, intent === "enable" || intent === "disable" ? "manage_integrations" : "run");
126 if (refused) return { error: refused };
127 if (intent === "redeploy") {
128 const started = await deployments.redeploy(user, ref, branch);
129 return started.ok ? { notice: "Build started." } : { error: started.error.message };
130 }
131 if (intent === "take-down") {
132 const done = await deployments.takeDown(user, ref, branch);
133 return done.ok ? { notice: "Taken down." } : { error: done.error.message };
134 }
135 if (intent === "enable" || intent === "disable") {
136 const saved = await deployments.updateSettings(user, ref, { enabled: intent === "enable" });
137 return saved.ok
138 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
139 : { error: saved.error.message };
140 }
141 return { error: "Unknown request." };
142}
143
144type Loaded = Route.ComponentProps["loaderData"];
145type Pages = Exclude<NonNullable<Loaded["pages"]>, Failed>;
146
147export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
148 const { filter, can } = loaderData;
149 // A part that could not be read says so in its place; the rest reads it as absent.
150 const environmentsFailed = isFailed(loaderData.environments);
151 const listFailed = isFailed(loaderData.list);
152 const pagesFailed = isFailed(loaderData.pages);
153 const environments = orElse(loaderData.environments, null);
154 const list = orElse(loaderData.list, null);
155 const pages = orElse(loaderData.pages, null);
156 const base = `/${params.owner}/${params.repo}`;
157 const total = environments?.total_count ?? list?.total_count ?? 0;
158 const envs = environments?.environments ?? [];
159 const pagesOn = pages?.settings.enabled ?? false;
160 // Anything to show besides how to start: deployments, or g1t.page hosting
161 // turned on. What could not be read may be there: never say there is
162 // nothing because of it.
163 const any = total > 0 || envs.length > 0 || pagesOn || (pages?.live.length ?? 0) > 0 || environmentsFailed || listFailed || pagesFailed;
164 const production = envs.find((env) => env.production_environment && environmentUrl(env));
165 const productionUrl = production ? environmentUrl(production) : null;
166 const planNeeded = pages?.plan != null && !pages.plan.on && !pages.plan.included && pages.live.length === 0;
167
168 return (
169 <div>
170 <header className="flex flex-wrap items-start justify-between gap-4">
171 <div className="min-w-0">
172 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
173 <Rocket size={18} className="text-accent" />
174 Deployments
175 {total > 0 && (
176 <span className="rounded-full bg-line px-2 py-px text-xs font-medium tabular-nums text-muted">{total.toLocaleString("en-US")}</span>
177 )}
178 </h1>
179 <p className="mt-1 max-w-2xl text-sm text-muted">
180 Where this repository runs, and every deployment to it: builds on g1t.page, g1t Actions jobs with an environment,
181 and deployments reported from any other CI.
182 </p>
183 </div>
184 {productionUrl && (
185 <DeployLink
186 href={productionUrl}
187 className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-line px-3 py-1.5 font-mono text-xs text-muted hover:border-line-strong hover:text-fg"
188 >
189 <Globe size={13} className="shrink-0" />
190 <span className="truncate">{host(productionUrl)}</span>
191 </DeployLink>
192 )}
193 </header>
194
195 <div className="mt-4 min-h-6">
196 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
197 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
198 {!actionData && pages && <ComputeNote note={pages.computeNote} />}
199 </div>
200
201 {environmentsFailed && listFailed ? (
202 <Unavailable what="Deployments" />
203 ) : !environments && !list && !environmentsFailed && !listFailed ? (
204 <Card asChild className="p-6 text-sm text-muted">
205 <p>
206 Deployments could not be loaded just now. Reload the page to try again.
207 </p>
208 </Card>
209 ) : !any ? (
210 <Start base={base} pages={pages} can={can} owner={params.owner} planNeeded={planNeeded} />
211 ) : (
212 <>
213 {environmentsFailed ? (
214 <section aria-labelledby="environments">
215 <h2 id="environments" className="text-sm font-semibold">
216 Environments
217 </h2>
218 <div className="mt-3">
219 <Unavailable what="Environments" />
220 </div>
221 </section>
222 ) : (
223 envs.length > 0 && (
224 <Boundary what="Environments">
225 <section aria-labelledby="environments">
226 <h2 id="environments" className="text-sm font-semibold">
227 Environments <span className="font-normal text-faint">{envs.length}</span>
228 </h2>
229 <ul className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">
230 {envs.map((env) => (
231 <EnvironmentCard key={env.name} env={env} base={base} />
232 ))}
233 </ul>
234 </section>
235 </Boundary>
236 )
237 )}
238
239 {listFailed ? (
240 <section id="history" aria-labelledby="history-heading" className="mt-10 scroll-mt-20">
241 <h2 id="history-heading" className="text-sm font-semibold">
242 History
243 </h2>
244 <div className="mt-3">
245 <Unavailable what="Deployment history" />
246 </div>
247 </section>
248 ) : (
249 <Boundary what="Deployment history">
250 <History base={base} list={list} filter={filter} environments={envs} />
251 </Boundary>
252 )}
253 </>
254 )}
255
256 {/* g1t.page hosting: its own section once there is anything else to show. */}
257 {any && pagesFailed && (
258 <section aria-labelledby="g1t-page" className="mt-12 border-t border-line pt-8">
259 <h2 id="g1t-page" className="flex items-center gap-2 text-sm font-semibold">
260 <Globe size={14} className="text-faint" />
261 On g1t.page
262 </h2>
263 <div className="mt-3">
264 <Unavailable what="g1t.page hosting" />
265 </div>
266 </section>
267 )}
268 {any && pages && (
269 <section aria-labelledby="g1t-page" className="mt-12 border-t border-line pt-8">
270 <h2 id="g1t-page" className="flex items-center gap-2 text-sm font-semibold">
271 <Globe size={14} className="text-faint" />
272 On g1t.page
273 </h2>
274 {planNeeded ? (
275 <PlanNeeded plan={pages.plan} owner={params.owner} quiet />
276 ) : !pagesOn ? (
277 <PagesOff pages={pages} base={base} can={can} quiet />
278 ) : (
279 <PagesOn pages={pages} base={base} can={can} />
280 )}
281 </section>
282 )}
283 </div>
284 );
285}
286
287/** Nothing has deployed yet: the two ways to start. */
288function Start({
289 base,
290 pages,
291 can,
292 owner,
293 planNeeded,
294}: {
295 base: string;
296 pages: Pages | null;
297 can: Loaded["can"];
298 owner: string;
299 planNeeded: boolean;
300}) {
301 return (
302 <Card asChild>
303 <section>
304 <div className="border-b border-line px-6 py-5">
305 <h2 className="font-medium">No deployments yet</h2>
306 <p className="mt-1 max-w-2xl text-sm text-muted">
307 Host it here on g1t.page, or deploy it wherever it runs and report each deployment. Either way they show here, by
308 environment.
309 </p>
310 </div>
311 <div className="grid divide-y divide-line md:grid-cols-2 md:divide-x md:divide-y-0">
312 <div className="min-w-0 p-6">
313 <h3 className="flex items-center gap-2 text-sm font-medium">
314 <Globe size={14} className="text-accent" />
315 Host it on g1t.page
316 </h3>
317 {!pages ? (
318 <p className="mt-1.5 text-sm text-muted">
319 Members of the workspace can turn on hosting: production from the default branch, and a live preview for every
320 pull request.
321 </p>
322 ) : planNeeded ? (
323 <PlanNeeded plan={pages.plan} owner={owner} quiet />
324 ) : (
325 <PagesOff pages={pages} base={base} can={can} />
326 )}
327 </div>
328 <div className="min-w-0 p-6">
329 <h3 className="flex items-center gap-2 text-sm font-medium">
330 <Terminal size={14} className="text-accent" />
331 Report deployments from your CI
332 </h3>
333 <p className="mt-1.5 text-sm text-muted">
334 A g1t Actions job with an <code className="font-mono text-fg">environment:</code> reports its deployment by
335 itself. From any other CI, create a deployment and its statuses with the API.
336 </p>
337 <Card asChild tone="bg" radius="lg" className="mt-3 overflow-x-auto p-3 font-mono text-xs leading-relaxed text-muted">
338 <pre>
339 {"jobs:\n deploy:\n environment:\n name: production\n url: https://example.com"}
340 </pre>
341 </Card>
342 <a href={API_GUIDE} className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline">
343 Reporting deployments <ArrowUpRight size={13} />
344 </a>
345 </div>
346 </div>
347 </section>
348 </Card>
349 );
350}
351
352function EnvironmentCard({ env, base }: { env: DeploymentEnvironment; base: string }) {
353 const latest = env.latest;
354 const current = env.current;
355 const url = environmentUrl(env);
356 // What it serves, when a newer deployment has not (yet) replaced it.
357 const serving = current && latest && current.id !== latest.id ? current : null;
358 const filtered = `${base}/deployments?environment=${encodeURIComponent(env.name)}#history`;
359 return (
360 <Card asChild className="flex min-w-0 flex-col p-4">
361 <li>
362 <div className="flex min-w-0 items-center gap-2">
363 <Link to={filtered} className="min-w-0 truncate font-medium hover:text-accent">
364 {environmentLabel(env.name)}
365 </Link>
366 {env.production_environment && env.name !== "production" && <Badge tone="accent">Production</Badge>}
367 {env.transient_environment && <Badge>Transient</Badge>}
368 <span className="grow" />
369 {latest && <DeploymentStateBadge state={latest.state} />}
370 </div>
371 {url ? (
372 <DeployLink href={url} className="mt-2 flex min-w-0 items-center gap-1.5 font-mono text-sm text-accent hover:underline">
373 <span className="truncate">{host(url)}</span>
374 <ExternalLink size={12} className="shrink-0" />
375 </DeployLink>
376 ) : (
377 <p className="mt-2 text-sm text-faint">No address</p>
378 )}
379 {latest && (
380 <dl className="mt-3 space-y-1.5 text-xs text-muted">
381 <div className="flex min-w-0 items-center gap-2">
382 <dt className="sr-only">Commit</dt>
383 <dd className="flex min-w-0 items-center gap-3">
384 <Link to={`${base}/commit/${latest.sha}`} className="inline-flex shrink-0 items-center gap-1 font-mono hover:text-fg">
385 <GitCommitHorizontal size={13} className="text-faint" />
386 {shortSha(latest.sha)}
387 </Link>
388 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
389 <GitBranch size={12} className="shrink-0 text-faint" />
390 <span className="truncate">{latest.ref}</span>
391 </span>
392 </dd>
393 </div>
394 <div className="flex min-w-0 items-center gap-1">
395 <dt className="sr-only">Deployed by</dt>
396 <dd className="min-w-0 truncate">
397 <Made deployment={latest} base={base} /> · <TimeAgo at={latest.updated_at} />
398 </dd>
399 </div>
400 {serving && (
401 <div className="flex min-w-0 items-center gap-1">
402 <dt className="sr-only">Serving</dt>
403 <dd className="min-w-0 truncate text-faint">
404 Serving{" "}
405 <Link to={`${base}/deployments/${serving.id}`} className="font-mono hover:text-fg">
406 {shortSha(serving.sha)}
407 </Link>{" "}
408 since <TimeAgo at={serving.updated_at} />
409 </dd>
410 </div>
411 )}
412 </dl>
413 )}
414 <div className="mt-auto flex items-center gap-3 pt-3 text-xs">
415 <Link to={filtered} className="text-muted hover:text-fg">
416 {env.deployments_count.toLocaleString("en-US")} {env.deployments_count === 1 ? "deployment" : "deployments"}
417 </Link>
418 {latest && (
419 <Link to={`${base}/deployments/${latest.id}`} className="ml-auto inline-flex items-center gap-0.5 text-muted hover:text-fg">
420 Latest
421 <ChevronRight size={13} />
422 </Link>
423 )}
424 </div>
425 </li>
426 </Card>
427 );
428}
429
430/** Who made a deployment and with what: a person or g1t, and g1t Actions (its run), g1t.page or the API. */
431function Made({ deployment, base, link = true }: { deployment: RepoDeployment; base: string; link?: boolean }) {
432 const label = sourceLabel(deployment.source);
433 return (
434 <>
435 <span className="text-fg-soft">{deployment.creator}</span> via{" "}
436 {link && deployment.source === "actions" && deployment.run_url ? (
437 <Link to={deployment.run_url} className="underline-offset-2 hover:text-fg hover:underline">
438 {label}
439 </Link>
440 ) : link && deployment.source === "g1t_page" ? (
441 <Link to={`${base}/deployments#g1t-page`} className="underline-offset-2 hover:text-fg hover:underline">
442 {label}
443 </Link>
444 ) : (
445 label
446 )}
447 </>
448 );
449}
450
451function History({
452 base,
453 list,
454 filter,
455 environments,
456}: {
457 base: string;
458 list: Exclude<Loaded["list"], Failed>;
459 filter: ListFilter;
460 environments: DeploymentEnvironment[];
461}) {
462 const path = `${base}/deployments`;
463 const rows = list?.deployments ?? [];
464 // The choices of creators and refs: those seen on this page and as each environment's latest.
465 const seen = [...rows, ...environments.flatMap((env) => [env.latest, env.current])];
466 const envNames = environments.map((env) => env.name);
467 if (filter.environment && !envNames.includes(filter.environment)) envNames.push(filter.environment);
468 const total = list?.total_count ?? 0;
469 const pageCount = list ? Math.max(1, Math.ceil(total / list.per_page)) : 1;
470 return (
471 <section id="history" aria-labelledby="history-heading" className="mt-10 scroll-mt-20">
472 <h2 id="history-heading" className="text-sm font-semibold">
473 History
474 </h2>
475 <div className="mt-3 flex flex-wrap items-center gap-2">
476 <FilterMenu
477 label="Environment"
478 active={filter.environment ? environmentLabel(filter.environment) : undefined}
479 clearTo={filter.environment ? withFilter(path, filter, "environment", null) : undefined}
480 searchPlaceholder="Filter environments"
481 emptyText="No environment matches."
482 options={envNames.map((name) => ({
483 key: name,
484 to: withFilter(path, filter, "environment", name),
485 keywords: name,
486 selected: name === filter.environment,
487 label: <span className="truncate">{environmentLabel(name)}</span>,
488 }))}
489 />
490 <FilterMenu
491 label="State"
492 active={filter.state ? STATE_WORD[filter.state] : undefined}
493 clearTo={filter.state ? withFilter(path, filter, "state", null) : undefined}
494 searchPlaceholder="Filter states"
495 emptyText="No state matches."
496 options={STATES.map((state) => ({
497 key: state,
498 to: withFilter(path, filter, "state", state),
499 keywords: `${STATE_WORD[state]} ${state}`,
500 selected: state === filter.state,
501 label: (
502 <>
503 <DeploymentStateIcon state={state} size={14} />
504 {STATE_WORD[state]}
505 </>
506 ),
507 }))}
508 />
509 <FilterMenu
510 label="Source"
511 active={filter.source ? SOURCE_LABEL[filter.source] : undefined}
512 clearTo={filter.source ? withFilter(path, filter, "source", null) : undefined}
513 searchPlaceholder="Filter sources"
514 emptyText="No source matches."
515 options={SOURCES.map((source) => ({
516 key: source,
517 to: withFilter(path, filter, "source", source),
518 keywords: `${SOURCE_LABEL[source]} ${source}`,
519 selected: source === filter.source,
520 label: SOURCE_LABEL[source],
521 }))}
522 />
523 <FilterMenu
524 label="Creator"
525 active={filter.creator ?? undefined}
526 clearTo={filter.creator ? withFilter(path, filter, "creator", null) : undefined}
527 searchPlaceholder="Filter creators"
528 emptyText="No creator matches."
529 options={choices(seen, "creator", filter.creator).map((creator) => ({
530 key: creator,
531 to: withFilter(path, filter, "creator", creator),
532 keywords: creator,
533 selected: creator === filter.creator,
534 label: <span className="truncate">{creator}</span>,
535 }))}
536 />
537 <FilterMenu
538 label="Ref"
539 active={filter.ref ?? undefined}
540 clearTo={filter.ref ? withFilter(path, filter, "ref", null) : undefined}
541 searchPlaceholder="Filter refs"
542 emptyText="No ref matches."
543 options={choices(seen, "ref", filter.ref).map((ref) => ({
544 key: ref,
545 to: withFilter(path, filter, "ref", ref),
546 keywords: ref,
547 selected: ref === filter.ref,
548 label: <span className="truncate font-mono text-xs">{ref}</span>,
549 }))}
550 />
551 {isFiltered(filter) && (
552 <Link to={`${path}#history`} className="text-xs text-muted hover:text-fg">
553 Clear filters
554 </Link>
555 )}
556 </div>
557
558 <div className="mt-3">
559 {!list ? (
560 <Card asChild className="p-6 text-sm text-muted">
561 <p>
562 The history could not be loaded just now. Reload the page to try again.
563 </p>
564 </Card>
565 ) : rows.length === 0 ? (
566 <Card tone="plain" radius="lg" className="border-dashed px-6 py-12 text-center">
567 {isFiltered(filter) ? (
568 <>
569 <p className="font-medium">No deployments match</p>
570 <p className="mt-1.5 text-sm text-muted">
571 Try fewer filters, or{" "}
572 <Link to={`${path}#history`} className="text-fg hover:underline">
573 see every deployment
574 </Link>
575 .
576 </p>
577 </>
578 ) : filter.page > 1 ? (
579 <>
580 <p className="font-medium">No deployments on this page</p>
581 <p className="mt-1.5 text-sm text-muted">
582 <Link to={`${path}#history`} className="text-fg hover:underline">
583 Go to the first page
584 </Link>
585 .
586 </p>
587 </>
588 ) : (
589 <>
590 <p className="font-medium">No deployments yet</p>
591 <p className="mt-1.5 text-sm text-muted">
592 Push to the default branch to build production on g1t.page, or{" "}
593 <a href={API_GUIDE} className="text-fg hover:underline">
594 report a deployment
595 </a>{" "}
596 from your CI.
597 </p>
598 </>
599 )}
600 </Card>
601 ) : (
602 <Card asChild tone="plain" divided className="overflow-hidden">
603 <ul>
604 {rows.map((deployment) => (
605 <HistoryRow key={deployment.id} deployment={deployment} base={base} />
606 ))}
607 </ul>
608 </Card>
609 )}
610 </div>
611
612 {list && total > 0 && (
613 <nav aria-label="Pages" className="mt-3 flex items-center gap-3 text-xs text-muted">
614 <span className="tabular-nums">{pageRange(list.page, list.per_page, total)}</span>
615 <span className="grow" />
616 {list.page > 1 ? (
617 <ButtonLink variant="outline" to={withFilter(path, filter, "page", list.page - 1)}>
618 <ChevronLeft size={14} />
619 Previous
620 </ButtonLink>
621 ) : (
622 <span className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium opacity-50">
623 <ChevronLeft size={14} />
624 Previous
625 </span>
626 )}
627 {list.page < pageCount ? (
628 <ButtonLink variant="outline" to={withFilter(path, filter, "page", list.page + 1)}>
629 Next
630 <ChevronRight size={14} />
631 </ButtonLink>
632 ) : (
633 <span className="inline-flex items-center gap-2 rounded-md border border-line px-3.5 py-2 text-sm font-medium opacity-50">
634 Next
635 <ChevronRight size={14} />
636 </span>
637 )}
638 </nav>
639 )}
640 </section>
641 );
642}
643
644function HistoryRow({ deployment, base }: { deployment: RepoDeployment; base: string }) {
645 return (
646 <li>
647 <Link
648 to={`${base}/deployments/${deployment.id}`}
649 className="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-surface"
650 >
651 <span className="mt-0.5">
652 <DeploymentStateIcon state={deployment.state} size={16} />
653 </span>
654 <span className="min-w-0 grow">
655 <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
656 <span className="font-medium">{environmentLabel(deployment.environment)}</span>
657 <span className={`text-xs ${deployment.state === "failure" || deployment.state === "error" ? "text-danger" : "text-muted"}`}>
658 {STATE_WORD[deployment.state]}
659 </span>
660 {deployment.task !== "deploy" && <Badge>{deployment.task}</Badge>}
661 </span>
662 <span className="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-muted">
663 <span className="inline-flex min-w-0 items-center gap-1 font-mono">
664 <GitBranch size={12} className="shrink-0 text-faint" />
665 <span className="truncate">{deployment.ref}</span>
666 </span>
667 <span className="shrink-0 font-mono text-faint">{shortSha(deployment.sha)}</span>
668 {deployment.description && <span className="hidden min-w-0 truncate sm:inline">· {deployment.description}</span>}
669 </span>
670 {deployment.description && <span className="mt-0.5 block truncate text-xs text-muted sm:hidden">{deployment.description}</span>}
671 {/* Who and when, under the rest on a narrow screen. */}
672 <span className="mt-0.5 block truncate text-xs text-faint sm:hidden">
673 <Made deployment={deployment} base={base} link={false} /> · <TimeAgo at={deployment.created_at} />
674 </span>
675 </span>
676 <span className="hidden shrink-0 text-right text-xs text-faint sm:block">
677 <span className="block">
678 <Made deployment={deployment} base={base} link={false} />
679 </span>
680 <span className="mt-0.5 block">
681 <TimeAgo at={deployment.created_at} />
682 </span>
683 </span>
684 </Link>
685 </li>
686 );
687}
688
689function PagesOn({ pages, base, can }: { pages: Pages; base: string; can: Loaded["can"] }) {
690 const { settings, live } = pages;
691 const production = live.find((app) => app.kind === "production");
692 const previews = live.filter((app) => app.kind === "preview");
693 return (
694 <>
695 <p className="mt-1 max-w-2xl text-sm text-muted">
696 Every pull request gets a live preview on g1t.page, and the default branch goes to production on each push. Apps run
697 only while someone visits them.{" "}
698 <a href="https://docs.g1t.sh/guides/deployments/" className="text-fg hover:underline">
699 How it works
700 </a>
701 </p>
702 <div className="mt-4 grid gap-4 md:grid-cols-2">
703 <LiveCard
704 title="Production"
705 hint="The default branch, on every push."
706 app={production}
707 url={production && settings.primaryDomain ? `https://${settings.primaryDomain}` : undefined}
708 off={!settings.production}
709 actions={can.run ? <AppActions branch={null} up={!!production} /> : null}
710 />
711 <Card className="min-w-0 p-5">
712 <h3 className="text-sm font-medium">Previews</h3>
713 <p className="mt-0.5 text-xs text-faint">
714 {settings.previews
715 ? `One per branch with an open pull request; down when it closes or after ${settings.idleDays} days without a visit.`
716 : "Off for this repository."}
717 </p>
718 {previews.length === 0 ? (
719 <p className="mt-4 text-sm text-muted">No previews are up.</p>
720 ) : (
721 <ul className="mt-3 divide-y divide-line">
722 {previews.map((app) => (
723 <li key={app.url} className="flex min-w-0 items-center gap-3 py-2 text-sm">
724 <span className="min-w-0 shrink truncate">
725 <span className="font-medium">{app.branch}</span>
726 {app.number != null && (
727 <Link to={`${base}/pull/${app.number}`} className="ml-1.5 text-xs text-muted hover:underline">
728 #{app.number}
729 </Link>
730 )}
731 </span>
732 <DeployLink href={app.url} className="hidden min-w-0 truncate font-mono text-xs text-muted hover:text-fg sm:block">
733 {host(app.url)}
734 </DeployLink>
735 <span className="ml-auto shrink-0">{can.run && <AppActions branch={app.branch} up compact />}</span>
736 </li>
737 ))}
738 </ul>
739 )}
740 </Card>
741 </div>
742 <p className="mt-4 text-sm text-muted">
743 Its builds are in the history above, under{" "}
744 <Link to={`${base}/deployments?source=g1t_page#history`} className="text-fg hover:underline">
745 g1t.page
746 </Link>
747 . Build command, output directory, idle days, and turning hosting off are under{" "}
748 <Link to={`${base}/settings/deployments`} className="text-fg hover:underline">
749 Settings → Deployments
750 </Link>
751 . Secrets and config for builds and running apps are under{" "}
752 <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
753 Settings → Secrets and variables
754 </Link>
755 , and production's own domains under{" "}
756 <Link to={`${base}/settings/domains`} className="text-fg hover:underline">
757 Settings → Domains
758 </Link>
759 .
760 </p>
761 </>
762 );
763}
764
765/** g1t.page hosting is off: what turning it on does, and the button. */
766function PagesOff({ pages, base, can, quiet }: { pages: Pages; base: string; can: Loaded["can"]; quiet?: boolean }) {
767 return (
768 <>
769 <p className="mt-1.5 max-w-2xl text-sm text-muted">
770 {quiet ? "This repository can also be hosted here. " : ""}
771 Once on, production goes up at <span className="font-mono text-fg wrap-anywhere">{host(pages.settings.productionUrl)}</span>{" "}
772 and every open pull request gets its own preview. Workers projects (a{" "}
773 <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration.
774 </p>
775 {pages.notDeploying ? (
776 <p className="mt-4 max-w-2xl text-sm text-muted">
777 This project is set to be something that doesn't deploy here, such as a library, a tool or an app that
778 deploys itself. To host it on g1t.page, choose App or site, hosted on g1t.page, in{" "}
779 <Link to={`${base}/settings#kind`} className="text-fg underline underline-offset-4">
780 its settings
781 </Link>{" "}
782 first.
783 </p>
784 ) : (
785 <Form method="post" className="mt-4">
786 <Hint label={whyNot(can, "manage_integrations")} disabled={!can.manage_integrations}>
787 <SubmitButton
788 variant={quiet ? "outline" : "accent"}
789 name="intent"
790 value="enable"
791 pending="Turning on…"
792 disabled={!can.manage_integrations}
793 >
794 <Rocket size={14} />
795 Turn on g1t.page hosting
796 </SubmitButton>
797 </Hint>
798 </Form>
799 )}
800 </>
801 );
802}
803
804function PlanNeeded({ plan, owner, quiet }: { plan: FeatureState | null; owner: string; quiet?: boolean }) {
805 return (
806 <div className={quiet ? "mt-1.5" : "mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6"}>
807 {!quiet && <h2 className="font-medium">Deployments are part of a paid plan</h2>}
808 <p className="max-w-2xl text-sm text-muted">
809 Hosting on g1t.page is part of a paid plan. Turn on Deployments for the {owner} workspace and every project in it can
810 have a preview for each pull request and production on g1t.page.
811 {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`}
812 </p>
813 {plan && (
814 <ul className="mt-3 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
815 {plan.plan.includes.map((line) => (
816 <li key={line} className="flex gap-2">
817 <span className="text-success">✓</span>
818 {line}
819 </li>
820 ))}
821 </ul>
822 )}
823 {plan && <p className="mt-3 text-xs text-faint">{plan.plan.overage}</p>}
824 <div className="mt-4">
825 <ButtonLink variant={quiet ? "outline" : "accent"} to={`/${owner}/-/billing`}>
826 See the plan under Billing
827 </ButtonLink>
828 </div>
829 </div>
830 );
831}
832
833function LiveCard({
834 title,
835 hint,
836 app,
837 url,
838 off,
839 actions,
840}: {
841 title: string;
842 hint: string;
843 app: { url: string; commit: string; deployedAt: string } | undefined;
844 /** The project's own domain, when it has one. */
845 url?: string;
846 off: boolean;
847 actions: ReactNode;
848}) {
849 const href = url ?? app?.url;
850 return (
851 <Card className="min-w-0 p-5">
852 <h3 className="text-sm font-medium">{title}</h3>
853 <p className="mt-0.5 text-xs text-faint">{off ? "Off for this repository." : hint}</p>
854 {app && href ? (
855 <>
856 <DeployLink href={href} className="mt-3 flex min-w-0 items-center gap-1.5 font-mono text-sm text-accent hover:underline">
857 <span className="truncate">{host(href)}</span>
858 <ExternalLink size={12} className="shrink-0" />
859 </DeployLink>
860 <p className="mt-1 text-xs text-faint">
861 <span className="font-mono">{app.commit.slice(0, 8)}</span> · deployed <TimeAgo at={app.deployedAt} />
862 </p>
863 </>
864 ) : (
865 <p className="mt-4 text-sm text-muted">Not up.</p>
866 )}
867 {!off && <div className="mt-4">{actions}</div>}
868 </Card>
869 );
870}
871
872function AppActions({ branch, up, compact }: { branch: string | null; up: boolean; compact?: boolean }) {
873 // Which app's buttons say they are working: production has no branch.
874 const app = branch ?? "production";
875 return (
876 <Form method="post" className="flex items-center gap-2">
877 {branch != null && <input type="hidden" name="branch" value={branch} />}
878 <input type="hidden" name="app" value={app} />
879 <Hint label="Build again from the current head">
880 <SubmitButton
881 variant="outline"
882 name="intent"
883 value="redeploy"
884 match={{ app }}
885 icon={compact}
886 pending="Redeploying…"
887 aria-label={compact ? "Redeploy" : undefined}
888 >
889 <RotateCw size={13} />
890 {!compact && "Redeploy"}
891 </SubmitButton>
892 </Hint>
893 {up && (
894 <Hint label="Take it down now">
895 <SubmitButton
896 variant="outline"
897 name="intent"
898 value="take-down"
899 match={{ app }}
900 icon={compact}
901 pending="Taking down…"
902 aria-label={compact ? "Take down" : undefined}
903 >
904 <Trash2 size={13} />
905 {!compact && "Take down"}
906 </SubmitButton>
907 </Hint>
908 )}
909 </Form>
910 );
911}