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

374 lines16,475 bytesCodeBlame
1import { 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";
8import { Button, ButtonLink, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "../../components/ui";
9import { 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
31/** `KEY=value` lines as an object; blank lines and `#` comments are skipped. */
32function parseEnv(text: string): Record<string, string> {
33 const vars: Record<string, string> = {};
34 for (const line of text.split(/\r?\n/)) {
35 const trimmed = line.trim();
36 if (!trimmed || trimmed.startsWith("#")) continue;
37 const at = trimmed.indexOf("=");
38 if (at > 0) vars[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1).trim();
39 }
40 return vars;
41}
42
43export async function action({ request, params, context }: Route.ActionArgs) {
44 assertSameOrigin(request);
45 const user = requireUser(context, request);
46 const form = await request.formData();
47 const path = { namespace: params.owner, name: params.repo };
48 const intent = form.get("intent");
49 const number = form.get("number") ? Number(form.get("number")) : null;
50 if (intent === "redeploy") {
51 const started = await deployments.redeploy(user, path, number);
52 return started.ok ? { notice: "Build started." } : { error: started.error.message };
53 }
54 if (intent === "take-down") {
55 const done = await deployments.takeDown(user, path, number);
56 return done.ok ? { notice: "Taken down." } : { error: done.error.message };
57 }
58 if (intent === "enable" || intent === "disable") {
59 const saved = await deployments.updateSettings(user, path, { enabled: intent === "enable" });
60 return saved.ok
61 ? { notice: intent === "enable" ? "Deployments are on. Production is building." : "Deployments are off, and every app is down." }
62 : { error: saved.error.message };
63 }
64 const on = (name: string) => form.get(name) === "on";
65 const saved = await deployments.updateSettings(user, path, {
66 previews: on("previews"),
67 production: on("production"),
68 buildCommand: String(form.get("buildCommand") ?? ""),
69 outputDir: String(form.get("outputDir") ?? ""),
70 buildEnv: parseEnv(String(form.get("buildEnv") ?? "")),
71 idleDays: Number(form.get("idleDays")),
72 });
73 return saved.ok ? { notice: "Saved." } : { error: saved.error.message };
74}
75
76const STATUS: Record<DeployStatus, { label: string; tone: string }> = {
77 queued: { label: "Queued", tone: "text-muted" },
78 building: { label: "Building", tone: "text-warn" },
79 ready: { label: "Live", tone: "text-accent" },
80 failed: { label: "Failed", tone: "text-danger" },
81 skipped: { label: "Skipped", tone: "text-faint" },
82};
83
84function StatusDot({ status }: { status: DeployStatus }) {
85 const { label, tone } = STATUS[status];
86 return (
87 <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}>
88 <span className={`size-1.5 rounded-full bg-current ${status === "building" ? "animate-pulse" : ""}`} />
89 {label}
90 </span>
91 );
92}
93
94export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) {
95 const { settings, deployments: builds, live, plan } = loaderData;
96 const busy = useNavigation().state === "submitting";
97 const base = `/${params.owner}/${params.repo}`;
98 const production = live.find((app) => app.kind === "production");
99 const previews = live.filter((app) => app.kind === "preview");
100
101 return (
102 <div className="mx-auto max-w-5xl">
103 <header className="flex flex-wrap items-start justify-between gap-4">
104 <div>
105 <h1 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
106 <Rocket size={18} className="text-accent" />
107 Deployments
108 </h1>
109 <p className="mt-1 max-w-2xl text-sm text-muted">
110 Every pull request gets a live preview on g1t.page, and the default branch goes to production on each push.
111 Apps run on Cloudflare only while someone visits them.{" "}
112 <a href="https://docs.g1t.sh/guides/deployments/" className="text-fg hover:underline">
113 How it works
114 </a>
115 </p>
116 </div>
117 {settings.enabled && (
118 <a
119 href={settings.productionUrl}
120 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"
121 >
122 <Globe size={13} />
123 {settings.productionUrl.replace("https://", "")}
124 </a>
125 )}
126 </header>
127
128 <div className="mt-4 min-h-6">
129 {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>}
130 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
131 </div>
132
133 {!plan?.on ? (
134 <PlanNeeded plan={plan} owner={params.owner} />
135 ) : !settings.enabled ? (
136 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
137 <h2 className="font-medium">Deploy {params.repo}</h2>
138 <p className="mt-1 max-w-2xl text-sm text-muted">
139 Production goes up from <span className="font-mono text-fg">{settings.productionUrl.replace("https://", "")}</span>{" "}
140 as soon as you turn this on, and every open pull request gets its own preview. Workers projects (a{" "}
141 <code className="font-mono text-fg">wrangler.jsonc</code>) and static sites deploy without configuration.
142 </p>
143 <Form method="post" className="mt-4">
144 <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}>
145 <Rocket size={14} />
146 Turn on deployments
147 </Button>
148 </Form>
149 </section>
150 ) : (
151 <>
152 <section className="mt-2 grid gap-4 md:grid-cols-2">
153 <LiveCard
154 title="Production"
155 hint="The default branch, on every push."
156 app={production}
157 off={!settings.production}
158 actions={
159 <AppActions number={null} up={!!production} busy={busy} />
160 }
161 />
162 <div className="rounded-xl border border-line bg-surface p-5">
163 <h2 className="text-sm font-medium">Previews</h2>
164 <p className="mt-0.5 text-xs text-faint">
165 {settings.previews
166 ? `One per open pull request; down when it closes or after ${settings.idleDays} days without a visit.`
167 : "Off for this repository."}
168 </p>
169 {previews.length === 0 ? (
170 <p className="mt-4 text-sm text-muted">No previews are up.</p>
171 ) : (
172 <ul className="mt-3 divide-y divide-line">
173 {previews.map((app) => (
174 <li key={app.url} className="flex items-center gap-3 py-2 text-sm">
175 <Link to={`${base}/pull/${app.number}`} className="font-medium hover:underline">
176 #{app.number}
177 </Link>
178 <a href={app.url} className="min-w-0 truncate font-mono text-xs text-muted hover:text-fg">
179 {app.url.replace("https://", "")}
180 </a>
181 <span className="ml-auto shrink-0">
182 <AppActions number={app.number} up busy={busy} compact />
183 </span>
184 </li>
185 ))}
186 </ul>
187 )}
188 </div>
189 </section>
190
191 <h2 className="mt-10 text-sm font-medium text-muted">Recent builds</h2>
192 <div className="mt-3">
193 {builds.length === 0 ? (
194 <EmptyState title="No builds yet">Push to the default branch or open a pull request.</EmptyState>
195 ) : (
196 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
197 {builds.map((build) => (
198 <BuildRow key={build.id} build={build} base={base} />
199 ))}
200 </ul>
201 )}
202 </div>
203
204 <SettingsForm settings={settings} busy={busy} />
205
206 <section className="mt-10 rounded-xl border border-danger/30 p-5">
207 <h2 className="text-sm font-medium">Turn off deployments</h2>
208 <p className="mt-1 text-sm text-muted">
209 Takes production and every preview down now, and stops building. Nothing of this repository's keeps
210 running or costing anything. The workspace's plan stays on; turn it off under Billing.
211 </p>
212 <Form method="post" className="mt-3">
213 <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}>
214 Turn off deployments
215 </Button>
216 </Form>
217 </section>
218 </>
219 )}
220 </div>
221 );
222}
223
224function PlanNeeded({ plan, owner }: { plan: FeatureState | null; owner: string }) {
225 return (
226 <section className="mt-2 rounded-xl border border-accent/30 bg-accent/5 p-6">
227 <h2 className="font-medium">Deployments are part of a paid plan</h2>
228 <p className="mt-1 max-w-2xl text-sm text-muted">
229 Turn on Deployments for the {owner} workspace and every repository in it can have previews for each pull
230 request and production on g1t.page.
231 {plan && ` $${(plan.plan.monthlyCents / 100).toFixed(0)} a month, including:`}
232 </p>
233 {plan && (
234 <ul className="mt-3 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
235 {plan.plan.includes.map((line) => (
236 <li key={line} className="flex gap-2">
237 <span className="text-accent">✓</span>
238 {line}
239 </li>
240 ))}
241 </ul>
242 )}
243 {plan && <p className="mt-3 text-xs text-faint">{plan.plan.overage}</p>}
244 <div className="mt-4">
245 <ButtonLink variant="accent" to={`/${owner}/-/billing`}>
246 See the plan under Billing
247 </ButtonLink>
248 </div>
249 </section>
250 );
251}
252
253function LiveCard({
254 title,
255 hint,
256 app,
257 off,
258 actions,
259}: {
260 title: string;
261 hint: string;
262 app: { url: string; commit: string; deployedAt: string } | undefined;
263 off: boolean;
264 actions: ReactNode;
265}) {
266 return (
267 <div className="rounded-xl border border-line bg-surface p-5">
268 <h2 className="text-sm font-medium">{title}</h2>
269 <p className="mt-0.5 text-xs text-faint">{off ? "Off for this repository." : hint}</p>
270 {app ? (
271 <>
272 <a href={app.url} className="mt-3 flex items-center gap-1.5 font-mono text-sm text-accent hover:underline">
273 {app.url.replace("https://", "")}
274 <ExternalLink size={12} />
275 </a>
276 <p className="mt-1 text-xs text-faint">
277 <span className="font-mono">{app.commit.slice(0, 8)}</span> · deployed <TimeAgo at={app.deployedAt} />
278 </p>
279 </>
280 ) : (
281 <p className="mt-4 text-sm text-muted">Not up.</p>
282 )}
283 {!off && <div className="mt-4">{actions}</div>}
284 </div>
285 );
286}
287
288function AppActions({ number, up, busy, compact }: { number: number | null; up: boolean; busy: boolean; compact?: boolean }) {
289 return (
290 <Form method="post" className="flex items-center gap-2">
291 {number != null && <input type="hidden" name="number" value={number} />}
292 <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head">
293 <RotateCw size={13} />
294 {!compact && "Redeploy"}
295 </Button>
296 {up && (
297 <Button variant="quiet" type="submit" name="intent" value="take-down" disabled={busy} title="Take it down now">
298 <Trash2 size={13} />
299 {!compact && "Take down"}
300 </Button>
301 )}
302 </Form>
303 );
304}
305
306function BuildRow({ build, base }: { build: Deployment; base: string }) {
307 return (
308 <li>
309 <Link to={`${base}/deployments/${build.id}`} className="flex items-center gap-4 px-4 py-3 text-sm hover:bg-surface">
310 <span className="w-20 shrink-0">
311 <StatusDot status={build.status} />
312 </span>
313 <span className="min-w-0 grow">
314 <span className="block truncate font-medium">
315 {build.kind === "production" ? "Production" : `Preview of #${build.number}`}
316 <span className="ml-2 font-mono text-xs font-normal text-faint">{build.commit.slice(0, 8)}</span>
317 </span>
318 {build.error && <span className="mt-0.5 block truncate text-xs text-muted">{build.error}</span>}
319 </span>
320 <span className="shrink-0 text-xs text-faint">
321 {build.buildSeconds != null && `${build.buildSeconds} s · `}
322 <TimeAgo at={build.createdAt} />
323 </span>
324 </Link>
325 </li>
326 );
327}
328
329function SettingsForm({ settings, busy }: { settings: Route.ComponentProps["loaderData"]["settings"]; busy: boolean }) {
330 const env = Object.entries(settings.buildEnv)
331 .map(([name, value]) => `${name}=${value}`)
332 .join("\n");
333 return (
334 <Form method="post" className="mt-10 space-y-5">
335 <h2 className="text-sm font-medium text-muted">Settings</h2>
336 <div className="grid gap-3 md:grid-cols-2">
337 <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
338 <input type="checkbox" name="production" defaultChecked={settings.production} className="mt-1 accent-accent" />
339 <span>
340 <span className="block text-sm font-medium">Production</span>
341 <span className="mt-1 block text-sm text-muted">Deploy the default branch on every push.</span>
342 </span>
343 </label>
344 <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-line bg-surface p-4 hover:border-line-strong">
345 <input type="checkbox" name="previews" defaultChecked={settings.previews} className="mt-1 accent-accent" />
346 <span>
347 <span className="block text-sm font-medium">Previews</span>
348 <span className="mt-1 block text-sm text-muted">A preview for every open pull request, linked on it.</span>
349 </span>
350 </label>
351 </div>
352 <div className="grid gap-4 md:grid-cols-3">
353 <Field label="Build command" hint="Instead of the project's own build script.">
354 <Input name="buildCommand" defaultValue={settings.buildCommand ?? ""} placeholder="npm run build" />
355 </Field>
356 <Field label="Output directory" hint="For a static site; found by itself when empty.">
357 <Input name="outputDir" defaultValue={settings.outputDir ?? ""} placeholder="dist" />
358 </Field>
359 <Field label="Idle days" hint="A preview no one visits for this long comes down.">
360 <Input name="idleDays" type="number" min={1} max={90} defaultValue={settings.idleDays} />
361 </Field>
362 </div>
363 <Field
364 label="Build variables"
365 hint="KEY=value, one per line. The build runs with them; they are not secret, so keep keys in Secrets."
366 >
367 <Textarea name="buildEnv" rows={4} defaultValue={env} className="font-mono" placeholder="NODE_ENV=production" />
368 </Field>
369 <Button type="submit" disabled={busy}>
370 Save settings
371 </Button>
372 </Form>
373 );
374}