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