| 1 | /** |
| 2 | * Guardrails: the form a workspace sets its defaults with and a project its |
| 3 | * overrides: the network a sandbox may reach, and how long its runs may take. |
| 4 | */ |
| 5 | import { type ReactNode, useState } from "react"; |
| 6 | import { Form } from "react-router"; |
| 7 | |
| 8 | import { type GuardrailSettings, type Guardrails, type GuardrailsView, RUN_KIND_LABEL } from "@g1t/contracts"; |
| 9 | |
| 10 | import { SANDBOX_RUN_KINDS, tri, workflowDomainLine } from "../lib/guardrails"; |
| 11 | import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui"; |
| 12 | import { Card } from "./ui/card"; |
| 13 | import { CheckboxOption } from "./ui/checkbox"; |
| 14 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 15 | import { Textarea } from "./ui/textarea"; |
| 16 | |
| 17 | function Section({ title, about, children }: { title: string; about: ReactNode; children: ReactNode }) { |
| 18 | return ( |
| 19 | <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]"> |
| 20 | <div> |
| 21 | <h2 className="font-medium">{title}</h2> |
| 22 | <div className="mt-1 text-sm text-muted">{about}</div> |
| 23 | </div> |
| 24 | <div className="min-w-0 space-y-3">{children}</div> |
| 25 | </section> |
| 26 | ); |
| 27 | } |
| 28 | |
| 29 | /** A choice of inheriting, or on or off, for one setting. */ |
| 30 | function TriSelect({ |
| 31 | name, |
| 32 | value, |
| 33 | inherited, |
| 34 | parent, |
| 35 | labels, |
| 36 | title, |
| 37 | children, |
| 38 | }: { |
| 39 | name: string; |
| 40 | value: boolean | null | undefined; |
| 41 | inherited: boolean; |
| 42 | parent: string; |
| 43 | labels: [on: string, off: string]; |
| 44 | title: string; |
| 45 | children: ReactNode; |
| 46 | }) { |
| 47 | return ( |
| 48 | <Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start"> |
| 49 | <div className="min-w-0 grow"> |
| 50 | <p className="text-sm font-medium">{title}</p> |
| 51 | <p className="mt-1 text-sm text-muted">{children}</p> |
| 52 | </div> |
| 53 | <Select name={name} defaultValue={tri(value)}> |
| 54 | <SelectTrigger size="sm" aria-label={title} className="w-full shrink-0 sm:w-auto sm:min-w-44"> |
| 55 | <SelectValue /> |
| 56 | </SelectTrigger> |
| 57 | <SelectContent align="end"> |
| 58 | <SelectItem value="inherit"> |
| 59 | As {parent} ({(inherited ? labels[0] : labels[1]).toLowerCase()}) |
| 60 | </SelectItem> |
| 61 | <SelectItem value="on">{labels[0]}</SelectItem> |
| 62 | <SelectItem value="off">{labels[1]}</SelectItem> |
| 63 | </SelectContent> |
| 64 | </Select> |
| 65 | </Card> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | /** Hosts in a compact list. */ |
| 70 | function Hosts({ hosts }: { hosts: string[] }) { |
| 71 | return ( |
| 72 | <span className="font-mono text-xs break-words text-faint">{hosts.length ? hosts.join(", ") : "none"}</span> |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * One level's guardrails. `level` says which: the workspace's defaults, |
| 78 | * over g1t's, or a project's overrides, over its workspace's. |
| 79 | */ |
| 80 | export function GuardrailsForm({ |
| 81 | view, |
| 82 | level, |
| 83 | editable, |
| 84 | saving, |
| 85 | saved, |
| 86 | error, |
| 87 | }: { |
| 88 | view: GuardrailsView; |
| 89 | level: "workspace" | "project"; |
| 90 | editable: boolean; |
| 91 | /** Working for a reason the Save button cannot see; it follows its own submission by itself. */ |
| 92 | saving?: boolean; |
| 93 | saved: boolean; |
| 94 | error: string | null | undefined; |
| 95 | }) { |
| 96 | const own: GuardrailSettings = (level === "project" ? view.project : view.workspace) ?? {}; |
| 97 | // What this level inherits, and what it is called. |
| 98 | const base: Guardrails = level === "project" ? view.inherited : view.defaults; |
| 99 | const parent = level === "project" ? "the workspace" : "g1t's default"; |
| 100 | const [registryMode, setRegistryMode] = useState(own.registries ? "custom" : "inherit"); |
| 101 | const shownRegistries = own.registries ?? base.registries; |
| 102 | const inheritedDomains = level === "project" ? view.inherited.domains : []; |
| 103 | const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : []; |
| 104 | return ( |
| 105 | // Keyed to the last change, so after a save the fields show what was kept, as it was tidied. |
| 106 | <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8"> |
| 107 | <fieldset disabled={!editable} className="min-w-0 space-y-8"> |
| 108 | <Section |
| 109 | title="Network" |
| 110 | about={ |
| 111 | <> |
| 112 | Which hosts a sandbox may reach. Requests anywhere else are refused at the sandbox's edge, and |
| 113 | each refused host shows on the run as a step. |
| 114 | </> |
| 115 | } |
| 116 | > |
| 117 | <TriSelect |
| 118 | name="restrictNetwork" |
| 119 | value={own.restrictNetwork} |
| 120 | inherited={base.restrictNetwork} |
| 121 | parent={parent} |
| 122 | labels={["Restricted", "Open"]} |
| 123 | title="Only allowed hosts" |
| 124 | > |
| 125 | Restricted, a sandbox reaches g1t, the registries below and the domains you list, over HTTP and |
| 126 | HTTPS only. Open, it reaches the whole internet. |
| 127 | </TriSelect> |
| 128 | <Card className="p-4"> |
| 129 | <p className="text-sm font-medium">Always allowed</p> |
| 130 | <p className="mt-1 text-sm text-muted"> |
| 131 | g1t's own hosts, for cloning, pushing and reporting: <Hosts hosts={view.g1tHosts} /> |
| 132 | </p> |
| 133 | </Card> |
| 134 | <Card className="p-4"> |
| 135 | <div className="flex flex-col gap-3 sm:flex-row sm:items-start"> |
| 136 | <div className="min-w-0 grow"> |
| 137 | <p className="text-sm font-medium">Package registries</p> |
| 138 | <p className="mt-1 text-sm text-muted">Where installs fetch dependencies from.</p> |
| 139 | </div> |
| 140 | <Select name="registries" value={registryMode} onValueChange={setRegistryMode}> |
| 141 | <SelectTrigger size="sm" aria-label="Package registries" className="w-full shrink-0 sm:w-auto sm:min-w-44"> |
| 142 | <SelectValue /> |
| 143 | </SelectTrigger> |
| 144 | <SelectContent align="end"> |
| 145 | <SelectItem value="inherit">As {parent}</SelectItem> |
| 146 | <SelectItem value="custom">Choose</SelectItem> |
| 147 | </SelectContent> |
| 148 | </Select> |
| 149 | </div> |
| 150 | <div className="mt-4 grid gap-3 sm:grid-cols-2"> |
| 151 | {view.registries.map((registry) => ( |
| 152 | <CheckboxOption |
| 153 | key={`${registry.id}-${registryMode}`} |
| 154 | name={`registry:${registry.id}`} |
| 155 | disabled={registryMode !== "custom"} |
| 156 | defaultChecked={(registryMode === "custom" ? shownRegistries : base.registries).includes(registry.id)} |
| 157 | label={registry.name} |
| 158 | description={registry.hosts.join(", ")} |
| 159 | /> |
| 160 | ))} |
| 161 | </div> |
| 162 | </Card> |
| 163 | <Card className="p-4"> |
| 164 | <label htmlFor="guardrail-domains" className="text-sm font-medium"> |
| 165 | Allowed domains |
| 166 | </label> |
| 167 | <p className="mt-1 text-sm text-muted"> |
| 168 | One per line: <span className="font-mono text-xs">api.stripe.com</span>, or{" "} |
| 169 | <span className="font-mono text-xs">*.example.com</span> for its subdomains. |
| 170 | {level === "project" && " These add to the workspace's."} |
| 171 | </p> |
| 172 | <Textarea |
| 173 | id="guardrail-domains" |
| 174 | name="domains" |
| 175 | className="mt-3 font-mono text-xs" |
| 176 | rows={4} |
| 177 | defaultValue={(own.domains ?? []).join("\n")} |
| 178 | placeholder="api.stripe.com" |
| 179 | /> |
| 180 | {inheritedDomains.length > 0 && ( |
| 181 | <p className="mt-2 text-xs text-faint"> |
| 182 | From the workspace: <Hosts hosts={inheritedDomains} /> |
| 183 | </p> |
| 184 | )} |
| 185 | </Card> |
| 186 | <Card className="p-4"> |
| 187 | <label htmlFor="guardrail-workflow-domains" className="text-sm font-medium"> |
| 188 | Workflow-only domains |
| 189 | </label> |
| 190 | <p className="mt-1 text-sm text-muted"> |
| 191 | Hosts that only workflow jobs may reach, such as the API a deploy uploads to. One |
| 192 | per line: the domain, then the workflows and the environments it is for, each comma-separated; |
| 193 | leave either out for any. Only jobs of runs that are not pull requests from forks get them. |
| 194 | {level === "project" && " These add to the workspace's."} |
| 195 | </p> |
| 196 | <Textarea |
| 197 | id="guardrail-workflow-domains" |
| 198 | name="workflowDomains" |
| 199 | className="mt-3 font-mono text-xs" |
| 200 | rows={3} |
| 201 | defaultValue={(own.workflowDomains ?? []).map(workflowDomainLine).join("\n")} |
| 202 | placeholder="api.cloudflare.com | deploy.yml | production" |
| 203 | /> |
| 204 | {inheritedWorkflowDomains.length > 0 && ( |
| 205 | <p className="mt-2 text-xs text-faint"> |
| 206 | From the workspace:{" "} |
| 207 | <span className="font-mono break-words">{inheritedWorkflowDomains.map(workflowDomainLine).join("; ")}</span> |
| 208 | </p> |
| 209 | )} |
| 210 | </Card> |
| 211 | </Section> |
| 212 | |
| 213 | <Section title="Time limits" about="How long one run may take. A run that reaches it is stopped."> |
| 214 | <Card className="p-4"> |
| 215 | <p className="text-sm font-medium">Time per run, in minutes</p> |
| 216 | <p className="mt-1 text-sm text-muted">Empty: as {parent}, shown faded.</p> |
| 217 | <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3"> |
| 218 | {SANDBOX_RUN_KINDS.map((kind) => ( |
| 219 | <label key={kind} className="flex flex-col gap-1 text-xs text-muted"> |
| 220 | {RUN_KIND_LABEL[kind]} |
| 221 | <Input |
| 222 | name={`minutes:${kind}`} |
| 223 | inputMode="numeric" |
| 224 | defaultValue={own.minutes?.[kind] == null ? "" : String(own.minutes[kind])} |
| 225 | placeholder={String(base.minutes[kind] ?? "")} |
| 226 | /> |
| 227 | </label> |
| 228 | ))} |
| 229 | </div> |
| 230 | </Card> |
| 231 | </Section> |
| 232 | </fieldset> |
| 233 | |
| 234 | {editable ? ( |
| 235 | <div className="sticky bottom-(--tabbar-h) in-data-[keyboard=open]:bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur"> |
| 236 | <SubmitButton pending="Saving…" busy={saving}> |
| 237 | Save guardrails |
| 238 | </SubmitButton> |
| 239 | {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>} |
| 240 | <ErrorText>{error}</ErrorText> |
| 241 | {own.updatedBy && own.updatedAt && !saved && !error && ( |
| 242 | <span className="text-xs text-faint"> |
| 243 | Last changed by <span className="font-mono">{own.updatedBy}</span> <TimeAgo at={own.updatedAt} /> |
| 244 | </span> |
| 245 | )} |
| 246 | </div> |
| 247 | ) : ( |
| 248 | <p className="text-sm text-muted"> |
| 249 | {level === "workspace" |
| 250 | ? "Only the workspace's owners can change its defaults." |
| 251 | : "Only members of the workspace can change a project's guardrails."} |
| 252 | </p> |
| 253 | )} |
| 254 | </Form> |
| 255 | ); |
| 256 | } |