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.page | 1 | import { ExternalLink, Globe, Rocket, RotateCw, Trash2 } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; | |
| 3 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 4 | ||
| 5 | import type { Deployment, DeployStatus, FeatureState } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import type { Route } from "./+types/deployments"; | |
| 8 | import { Button, ButtonLink, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "../../components/ui"; | |
| 9 | import { billing, deployments } from "../../lib/services.server"; | |
| 10 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 11 | ||
| 12 | export function meta({ params }: Route.MetaArgs) { | |
| 13 | return [{ title: `Deployments · ${params.owner}/${params.repo} · g1t` }]; | |
| 14 | } | |
| 15 | ||
| 16 | export 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. */ | |
| 32 | function 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 | ||
| 43 | export 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 | ||
| 76 | const 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 | ||
| 84 | function 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 | ||
| 94 | export 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 | ||
| 224 | function 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 | ||
| 253 | function 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 | ||
| 288 | function 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 | ||
| 306 | function 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 | ||
| 329 | function 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 | } |