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.

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