Skip to content
256 linesCodeBlameRaw
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 */
5import { type ReactNode, useState } from "react";
6import { Form } from "react-router";
7
8import { type GuardrailSettings, type Guardrails, type GuardrailsView, RUN_KIND_LABEL } from "@g1t/contracts";
9
10import { SANDBOX_RUN_KINDS, tri, workflowDomainLine } from "../lib/guardrails";
11import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui";
12import { Card } from "./ui/card";
13import { CheckboxOption } from "./ui/checkbox";
14import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
15import { Textarea } from "./ui/textarea";
16
17function 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. */
30function 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. */
70function 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 */
80export 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}