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