g1t/apps/web/app/routes/repo/deployments.tsx

303 lines12,856 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Deployments: a preview for every pull request, production on g1t.page1import { ExternalLink, Globe, Rocket, RotateCw, Trash2 } from "lucide-react";
2import type { ReactNode } from "react";
3import { Form, Link, data, useNavigation } from "react-router";
4
5import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts";
6
7import type { Route } from "./+types/deployments";
Secrets and variables: one list, rows per environment, for workflows and deployments8import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
Deployments: a preview for every pull request, production on g1t.page9import { billing, deployments } from "../../lib/services.server";
10import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
11
12export function meta({ params }: Route.MetaArgs) {
13 return [{ title: `Deployments · ${params.owner}/${params.repo} · g1t` }];
14}
15
16export async function loader({ params, context }: Route.LoaderArgs) {
17 const viewer = getViewer(context);
18 const role = roleIn(viewer, params.owner);
19 // Members only; to anyone else the page does not exist.
20 if (!role) throw data(null, { status: 404 });
21 const path = { namespace: params.owner, name: params.repo };
22 const [settings, list, features] = await Promise.all([
23 deployments.settings(path, viewer),
24 deployments.list(path, viewer),
25 billing.features(params.owner, viewer),
26 ]);
27 const plan = unwrap(features).find((state) => state.plan.feature === "deployments") ?? null;
28 return { role, settings: unwrap(settings), ...unwrap(list), plan };
29}
30
31export async function action({ request, params, context }: Route.ActionArgs) {
32 assertSameOrigin(request);
33 const user = requireUser(context, request);
34 const form = await request.formData();
35 const path = { namespace: params.owner, name: params.repo };
36 const intent = form.get("intent");
37 const number = form.get("number") ? Number(form.get("number")) : null;
38 if (intent === "redeploy") {
39 const started = await deployments.redeploy(user, path, number);
40 return started.ok ? { notice: "Build started." } : { error: started.error.message };
41 }
42 if (intent === "take-down") {
43 const done = await deployments.takeDown(user, path, number);
44 return done.ok ? { notice: "Taken down." } : { error: done.error.message };
45 }
46 if (intent === "enable" || intent === "disable") {
47 const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" });
48 return saved.ok
49 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
50 : { error: saved.error.message };
51 }
Secrets and variables: one list, rows per environment, for workflows and deployments52 return { error: "Unknown request." };
Deployments: a preview for every pull request, production on g1t.page53}
54
55const STATUS: Record<DeployStatus, { label: string; tone: string }> = {
56 queued: { label: "Queued", tone: "text-muted" },
57 building: { label: "Building", tone: "text-warn" },
58 ready: { label: "Live", tone: "text-accent" },
59 failed: { label: "Failed", tone: "text-danger" },
60 skipped: { label: "Skipped", tone: "text-faint" },
61};
62
63function StatusDot({ status }: { status: DeployStatus }) {
64 const { label, tone } = STATUS[status];
65 return (
66 <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}>
67 <span className={`size-1.5 rounded-full bg-current ${status === "building" ? "animate-pulse" : ""}`} />
68 {label}
69 </span>
70 );
71}
72
73export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
74 const { settings, deployments: builds, live, plan } = loaderData;
75 const busy = useNavigation().state === "submitting";
76 const base = `/${params.owner}/${params.repo}`;
77 const production = live.find((app) => app.kind === "production");
78 const previews = live.filter((app) => app.kind === "preview");
79
80 return (
81 <div className="mx-auto max-w-5xl">
82 <header className="flex flex-wrap items-start justify-between gap-4">
83 <div>
84 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
85 <Rocket size={18} className="text-accent" />
86 Deployments
87 </h1>
88 <p className="mt-1 max-w-2xl text-sm text-muted">
89 Every pull request gets a live preview on g1t.page, and the default branch goes to production on each push.
90 Apps run on Cloudflare only while someone visits them.{" "}
91 <a href="https://docs.g1t.sh/guides/deployments/" className="text-fg hover:underline">
92 How it works
93 </a>
94 </p>
95 </div>
96 {settings.enabled && (
97 <a
98 href={settings.productionUrl}
99 className="inline-flex 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"
100 >
101 <Globe size={13} />
102 {settings.productionUrl.replace("https://", "")}
103 </a>
104 )}
105 </header>
106
107 <div className="mt-4 min-h-6">
108 {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
109 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
110 </div>
111
112 {!plan?.on ? (
113 <PlanNeeded plan={plan} owner={params.owner} />
114 ) : !settings.enabled ? (
115 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
116 <h2 className="font-medium">Deploy {params.repo}</h2>
117 <p className="mt-1 max-w-2xl text-sm text-muted">
118 Production goes up from <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>{" "}
119 as soon as you turn this on, and every open pull request gets its own preview. Workers projects (a{" "}
120 <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration.
121 </p>
122 <Form method="post" className="mt-4">
123 <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
124 <Rocket size={14} />
125 Turn on deployments
126 </Button>
127 </Form>
128 </section>
129 ) : (
130 <>
131 <section className="mt-2 grid gap-4 md:grid-cols-2">
132 <LiveCard
133 title="Production"
134 hint="The default branch, on every push."
135 app={production}
136 off={!settings.production}
137 actions={
138 <AppActions number={null} up={!!production} busy={busy} />
139 }
140 />
141 <div className="rounded-xl border border-line bg-surface p-5">
142 <h2 className="text-sm font-medium">Previews</h2>
143 <p className="mt-0.5 text-xs text-faint">
144 {settings.previews
145 ? `One per open pull request; down when it closes or after ${settings.idleDays} days without a visit.`
146 : "Off for this repository."}
147 </p>
148 {previews.length === 0 ? (
149 <p className="mt-4 text-sm text-muted">No previews are up.</p>
150 ) : (
151 <ul className="mt-3 divide-y divide-line">
152 {previews.map((app) => (
153 <li key={app.url} className="flex items-center gap-3 py-2 text-sm">
154 <Link to={`${base}/pull/${app.number}`} className="font-medium hover:underline">
155 #{app.number}
156 </Link>
157 <a href={app.url} className="min-w-0 truncate font-mono text-xs text-muted hover:text-fg">
158 {app.url.replace("https://", "")}
159 </a>
160 <span className="ml-auto shrink-0">
161 <AppActions number={app.number} up busy={busy} compact />
162 </span>
163 </li>
164 ))}
165 </ul>
166 )}
167 </div>
168 </section>
169
170 <h2 className="mt-10 text-sm font-medium text-muted">Recent builds</h2>
171 <div className="mt-3">
172 {builds.length === 0 ? (
173 <EmptyState title="No builds yet">Push to the default branch or open a pull request.</EmptyState>
174 ) : (
175 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
176 {builds.map((build) => (
177 <BuildRow key={build.id} build={build} base={base} />
178 ))}
179 </ul>
180 )}
181 </div>
182
Secrets and variables: one list, rows per environment, for workflows and deployments183 <p className="mt-10 text-sm text-muted">
184 Build command, output directory, idle days, and turning deployments off are under{" "}
185 <Link to={`${base}/settings/deployments`} className="text-fg hover:underline">
186 Settings → Deployments
187 </Link>
188 . Secrets and config for builds and running apps are under{" "}
189 <Link to={`${base}/settings/secrets`} className="text-fg hover:underline">
190 Settings → Secrets and variables
191 </Link>
192 .
193 </p>
Deployments: a preview for every pull request, production on g1t.page194 </>
195 )}
196 </div>
197 );
198}
199
200function PlanNeeded({ plan, owner }: { plan: FeatureState | null; owner: string }) {
201 return (
202 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
203 <h2 className="font-medium">Deployments are part of a paid plan</h2>
204 <p className="mt-1 max-w-2xl text-sm text-muted">
205 Turn on Deployments for the {owner} workspace and every repository in it can have previews for each pull
206 request and production on g1t.page.
207 {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`}
208 </p>
209 {plan && (
210 <ul className="mt-3 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
211 {plan.plan.includes.map((line) => (
212 <li key={line} className="flex gap-2">
213 <span className="text-accent">✓</span>
214 {line}
215 </li>
216 ))}
217 </ul>
218 )}
219 {plan && <p className="mt-3 text-xs text-faint">{plan.plan.overage}</p>}
220 <div className="mt-4">
221 <ButtonLink variant="accent" to={`/${owner}/-/billing`}>
222 See the plan under Billing
223 </ButtonLink>
224 </div>
225 </section>
226 );
227}
228
229function LiveCard({
230 title,
231 hint,
232 app,
233 off,
234 actions,
235}: {
236 title: string;
237 hint: string;
238 app: { url: string; commit: string; deployedAt: string } | undefined;
239 off: boolean;
240 actions: ReactNode;
241}) {
242 return (
243 <div className="rounded-xl border border-line bg-surface p-5">
244 <h2 className="text-sm font-medium">{title}</h2>
245 <p className="mt-0.5 text-xs text-faint">{off ? "Off for this repository." : hint}</p>
246 {app ? (
247 <>
248 <a href={app.url} className="mt-3 flex items-center gap-1.5 font-mono text-sm text-accent hover:underline">
249 {app.url.replace("https://", "")}
250 <ExternalLink size={12} />
251 </a>
252 <p className="mt-1 text-xs text-faint">
253 <span className="font-mono">{app.commit.slice(0, 8)}</span> · deployed <TimeAgo at={app.deployedAt} />
254 </p>
255 </>
256 ) : (
257 <p className="mt-4 text-sm text-muted">Not up.</p>
258 )}
259 {!off && <div className="mt-4">{actions}</div>}
260 </div>
261 );
262}
263
264function AppActions({ number, up, busy, compact }: { number: number | null; up: boolean; busy: boolean; compact?: boolean }) {
265 return (
266 <Form method="post" className="flex items-center gap-2">
267 {number != null && <input type="hidden" name="number" value={number} />}
268 <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head">
269 <RotateCw size={13} />
270 {!compact && "Redeploy"}
271 </Button>
272 {up && (
273 <Button variant="quiet" type="submit" name="intent" value="take-down" disabled={busy} title="Take it down now">
274 <Trash2 size={13} />
275 {!compact && "Take down"}
276 </Button>
277 )}
278 </Form>
279 );
280}
281
282function BuildRow({ build, base }: { build: Deployment; base: string }) {
283 return (
284 <li>
285 <Link to={`${base}/deployments/${build.id}`} className="flex items-center gap-4 px-4 py-3 text-sm hover:bg-surface">
286 <span className="w-20 shrink-0">
287 <StatusDot status={build.status} />
288 </span>
289 <span className="min-w-0 grow">
290 <span className="block truncate font-medium">
291 {build.kind === "production" ? "Production" : `Preview of #${build.number}`}
292 <span className="ml-2 font-mono text-xs font-normal text-faint">{build.commit.slice(0, 8)}</span>
293 </span>
294 {build.error && <span className="mt-0.5 block truncate text-xs text-muted">{build.error}</span>}
295 </span>
296 <span className="shrink-0 text-xs text-faint">
297 {build.buildSeconds != null && `${build.buildSeconds} s · `}
298 <TimeAgo at={build.createdAt} />
299 </span>
300 </Link>
301 </li>
302 );
303}