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

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