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