g1t/apps/web/app/components/runners.tsx

459 lines20,881 bytesCodeBlame
1import { Bot, Boxes, Cpu, Plus, ServerCog, ShieldAlert, Trash2 } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, useNavigation } from "react-router";
4
5import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
6
7import type { RunnersAction, RunnersData } from "../lib/runners.server";
8import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, TimeAgo } from "./ui";
9import { Switch } from "./ui/switch";
10import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
11
12const STATUS: Record<Runner["status"], { dot: string; label: string }> = {
13 online: { dot: "bg-accent", label: "Idle" },
14 busy: { dot: "bg-info", label: "Busy" },
15 offline: { dot: "bg-faint", label: "Offline" },
16};
17
18const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" };
19
20function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) {
21 return (
22 <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]">
23 <div>
24 <h2 className="flex items-center gap-2 font-medium">
25 {icon}
26 {title}
27 </h2>
28 <div className="mt-1 text-sm text-muted">{about}</div>
29 </div>
30 <div className="min-w-0">{children}</div>
31 </section>
32 );
33}
34
35function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) {
36 const status = STATUS[runner.status];
37 const busy = useNavigation().state === "submitting";
38 return (
39 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
40 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden />
41 <div className="min-w-0 grow basis-60">
42 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
43 <span className="font-medium">{runner.name}</span>
44 <span className="text-xs text-muted">{status.label}</span>
45 {runner.ephemeral && <Pill>ephemeral</Pill>}
46 {runner.group && <span className="text-xs text-faint">in {runner.group}</span>}
47 </div>
48 <div className="mt-1.5 flex flex-wrap gap-1">
49 {runner.labels.map((label) => (
50 <span key={label} className="rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.6875rem] text-fg-soft">
51 {label}
52 </span>
53 ))}
54 </div>
55 {runner.work && (
56 <p className="mt-1.5 truncate text-xs text-muted">
57 {runner.work.kind === "workflow" ? "Running " : "Agent work: "}
58 {runner.work.runId && runner.work.repo ? (
59 <Link to={`${base(runner.work.repo)}/actions/runs/${runner.work.runId}`} className="text-fg hover:underline">
60 {runner.work.name}
61 </Link>
62 ) : (
63 <span className="text-fg">{runner.work.name}</span>
64 )}
65 {runner.work.repo && <> in {runner.work.repo}</>}
66 </p>
67 )}
68 </div>
69 <div className="shrink-0 text-right text-xs text-faint">
70 <div>
71 {OS_NAMES[runner.os]} {runner.arch}
72 {runner.version && <> · {runner.version}</>}
73 </div>
74 <div className="mt-0.5">{runner.lastSeenAt ? <>Seen <TimeAgo at={runner.lastSeenAt} /></> : "Never seen"}</div>
75 </div>
76 {manage && (
77 <Form
78 method="post"
79 className="shrink-0"
80 onSubmit={(event) => {
81 if (!confirm(`Remove ${runner.name}? Its credential stops working at once${runner.work ? ", and the job it is running fails" : ""}.`)) event.preventDefault();
82 }}
83 >
84 <input type="hidden" name="intent" value="remove" />
85 <input type="hidden" name="id" value={runner.id} />
86 <input type="hidden" name="name" value={runner.name} />
87 <button
88 type="submit"
89 disabled={busy}
90 aria-label={`Remove ${runner.name}`}
91 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
92 >
93 <Trash2 size={15} />
94 </button>
95 </Form>
96 )}
97 </li>
98 );
99}
100
101type Platform = "linux" | "macos" | "windows" | "docker";
102
103/** The copy-paste steps for one platform. */
104export function installSteps(platform: Platform, token: RegistrationToken | null, arch: "x64" | "arm64" = "x64"): { title: string; lines: string[] }[] {
105 const url = token?.url ?? "https://g1t.sh";
106 const value = token?.token ?? "<registration token>";
107 const register = (exe: string) => `${exe} register --url ${url} --token ${value}`;
108 switch (platform) {
109 case "linux": {
110 const file = arch === "arm64" ? RUNNER_FILES["linux-arm64"] : RUNNER_FILES["linux-x64"];
111 return [
112 { title: "Download", lines: [`curl -fsSLo g1t-runner ${RUNNER_DOWNLOADS}/latest/${file} && chmod +x g1t-runner`] },
113 { title: "Register", lines: [register("./g1t-runner")] },
114 { title: "Run it, or install it as a service", lines: ["./g1t-runner run", "sudo ./g1t-runner service install"] },
115 ];
116 }
117 case "macos": {
118 const file = arch === "x64" ? RUNNER_FILES["macos-x64"] : RUNNER_FILES["macos-arm64"];
119 return [
120 { title: "Download", lines: [`curl -fsSLo g1t-runner ${RUNNER_DOWNLOADS}/latest/${file} && chmod +x g1t-runner`] },
121 { title: "Register", lines: [register("./g1t-runner")] },
122 { title: "Run it, or install it as a launch agent", lines: ["./g1t-runner run", "./g1t-runner service install"] },
123 ];
124 }
125 case "windows":
126 return [
127 {
128 title: "Download (PowerShell)",
129 lines: [`Invoke-WebRequest ${RUNNER_DOWNLOADS}/latest/${RUNNER_FILES["windows-x64"]} -OutFile g1t-runner.exe`],
130 },
131 { title: "Register", lines: [register(".\\g1t-runner.exe")] },
132 { title: "Run it, or install it as a service (as Administrator)", lines: [".\\g1t-runner.exe run", ".\\g1t-runner.exe service install"] },
133 ];
134 case "docker":
135 return [
136 {
137 title: "Register and run, with jobs in sibling containers",
138 lines: [
139 `docker run -d --name g1t-runner --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v g1t-runner:/data -e G1T_RUNNER_DIR=/data ${RUNNER_IMAGE} register-and-run --url ${url} --token ${value}`,
140 ],
141 },
142 ];
143 }
144}
145
146function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
147 const [platform, setPlatform] = useState<Platform>("linux");
148 const [arch, setArch] = useState<"x64" | "arm64">("x64");
149 const busy = useNavigation().state === "submitting";
150 return (
151 <div className="space-y-4 rounded-xl border border-line bg-surface p-4">
152 {!token ? (
153 <Form method="post" className="flex flex-wrap items-end gap-3">
154 <input type="hidden" name="intent" value="token" />
155 {!repoScoped && groups.length > 1 && (
156 <Field label="Group">
157 <select name="group" className="h-9 rounded-md border border-line bg-bg px-2.5 text-sm" defaultValue={groups.find((g) => g.default)?.name}>
158 {groups.map((group) => (
159 <option key={group.id} value={group.name}>
160 {group.name}
161 </option>
162 ))}
163 </select>
164 </Field>
165 )}
166 <Button type="submit" disabled={busy}>
167 <Plus size={15} />
168 New runner
169 </Button>
170 <p className="basis-full text-xs text-faint">Makes a registration token that lasts an hour and registers any number of runners. It is shown once.</p>
171 </Form>
172 ) : (
173 <p className="text-sm">
174 Registration token made. It expires <TimeAgoFuture at={token.expiresAt} />
175 {token.group ? <>; its runners join {token.group}</> : null}. Copy it now: it is not shown again.
176 </p>
177 )}
178 <Tabs value={platform} onValueChange={(value) => setPlatform(value as Platform)}>
179 <div className="flex flex-wrap items-center gap-3">
180 <TabsList aria-label="Where the runner runs">
181 <TabsTrigger value="linux">Linux</TabsTrigger>
182 <TabsTrigger value="macos">macOS</TabsTrigger>
183 <TabsTrigger value="windows">Windows</TabsTrigger>
184 <TabsTrigger value="docker">Docker</TabsTrigger>
185 </TabsList>
186 {(platform === "linux" || platform === "macos") && (
187 <TabsList aria-label="Architecture">
188 <button
189 type="button"
190 onClick={() => setArch("x64")}
191 className={`rounded-md px-2.5 py-1 text-xs font-medium ${arch === "x64" ? "bg-raised text-fg" : "text-muted hover:text-fg"}`}
192 >
193 {platform === "macos" ? "Intel" : "x64"}
194 </button>
195 <button
196 type="button"
197 onClick={() => setArch("arm64")}
198 className={`rounded-md px-2.5 py-1 text-xs font-medium ${arch === "arm64" ? "bg-raised text-fg" : "text-muted hover:text-fg"}`}
199 >
200 {platform === "macos" ? "Apple silicon" : "arm64"}
201 </button>
202 </TabsList>
203 )}
204 </div>
205 </Tabs>
206 <ol className="space-y-3">
207 {installSteps(platform, token, platform === "macos" && arch === "x64" ? "x64" : arch).map((step, at) => (
208 <li key={step.title}>
209 <p className="mb-1.5 text-xs text-muted">
210 {at + 1}. {step.title}
211 </p>
212 <div className="space-y-1.5">
213 {step.lines.map((line) => (
214 <CopyLine key={line} text={line} prompt />
215 ))}
216 </div>
217 </li>
218 ))}
219 </ol>
220 <p className="text-xs text-faint">
221 Jobs run in Docker containers by default; add <code className="font-mono">--no-docker</code> to run them on the machine itself. Add{" "}
222 <code className="font-mono">--labels gpu,cuda</code> for jobs to ask for, or <code className="font-mono">--ephemeral</code> for a runner that takes
223 one job and removes itself.{" "}
224 <a href="https://docs.g1t.sh/guides/self-hosted-runners/" className="underline underline-offset-2">
225 Self-hosted runners
226 </a>
227 </p>
228 </div>
229 );
230}
231
232/** "in 59m", for a time still to come. */
233function TimeAgoFuture({ at }: { at: string }) {
234 const minutes = Math.max(0, Math.round((new Date(at).getTime() - Date.now()) / 60_000));
235 return (
236 <time dateTime={at} suppressHydrationWarning>
237 {minutes >= 60 ? "in an hour" : `in ${minutes}m`}
238 </time>
239 );
240}
241
242function Groups({ groups, repositories, manage }: { groups: RunnerGroup[]; repositories: string[]; manage: boolean }) {
243 const [editing, setEditing] = useState<string | null>(null);
244 return (
245 <div className="space-y-3">
246 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
247 {groups.map((group) => (
248 <li key={group.id} className="px-4 py-3">
249 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
250 <span className="font-medium">{group.name}</span>
251 {group.default && <Pill>default</Pill>}
252 <span className="text-xs text-muted">
253 {group.runners} {group.runners === 1 ? "runner" : "runners"} ·{" "}
254 {group.repositories.length === 0 ? "every repository" : group.repositories.join(", ")}
255 </span>
256 {manage && (
257 <span className="ml-auto flex items-center gap-2">
258 <Button type="button" variant="quiet" onClick={() => setEditing(editing === group.id ? null : group.id)}>
259 {editing === group.id ? "Close" : "Edit"}
260 </Button>
261 {!group.default && (
262 <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}>
263 <input type="hidden" name="intent" value="delete-group" />
264 <input type="hidden" name="id" value={group.id} />
265 <Button type="submit" variant="quiet">
266 Delete
267 </Button>
268 </Form>
269 )}
270 </span>
271 )}
272 </div>
273 {editing === group.id && <GroupForm group={group} repositories={repositories} />}
274 </li>
275 ))}
276 </ul>
277 {manage && (editing === "new" ? <GroupForm group={null} repositories={repositories} /> : (
278 <Button type="button" variant="quiet" onClick={() => setEditing("new")}>
279 <Plus size={15} />
280 New group
281 </Button>
282 ))}
283 </div>
284 );
285}
286
287function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) {
288 const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all");
289 const busy = useNavigation().state === "submitting";
290 return (
291 <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3">
292 <input type="hidden" name="intent" value="group" />
293 {group && <input type="hidden" name="id" value={group.id} />}
294 <Field label="Name">
295 <Input name="name" defaultValue={group?.name ?? ""} placeholder="GPU machines" maxLength={64} required={!group} />
296 </Field>
297 <fieldset className="space-y-2 text-sm">
298 <legend className="mb-1 text-xs text-muted">Repositories that may use its runners</legend>
299 <label className="flex items-center gap-2">
300 <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} />
301 Every repository in the workspace
302 </label>
303 <label className="flex items-center gap-2">
304 <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} />
305 Only these
306 </label>
307 {reach === "some" && (
308 <div className="grid gap-1 pl-6 sm:grid-cols-2">
309 {repositories.map((name) => (
310 <label key={name} className="flex items-center gap-2 font-mono text-xs">
311 <input type="checkbox" name="repository" value={name} defaultChecked={group?.repositories.includes(name)} />
312 {name}
313 </label>
314 ))}
315 </div>
316 )}
317 </fieldset>
318 <Button type="submit" disabled={busy}>
319 Save group
320 </Button>
321 </Form>
322 );
323}
324
325function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
326 const settings = data.settings;
327 const busy = useNavigation().state === "submitting";
328 const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false);
329 const [forks, setForks] = useState(settings?.forkPullRequests ?? false);
330 if (!settings) return null;
331 return (
332 <Form method="post" className="space-y-4">
333 <input type="hidden" name="intent" value="settings" />
334 {scope === "project" && settings.inherited && (
335 <p className="text-sm text-muted">These are the workspace&apos;s settings. Saving here gives this project its own.</p>
336 )}
337 <label className="flex items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4">
338 <span className="min-w-0">
339 <span className="flex items-center gap-2 text-sm font-medium">
340 <Bot size={15} className="text-muted" />
341 Run g1t's work on self-hosted runners
342 </span>
343 <span className="mt-1 block text-sm text-muted">
344 Agent runs, checks, reviews and the merge queue run on your runners with these labels instead of g1t&apos;s sandboxes. The machine time is
345 free; the model is paid as before, through g1t&apos;s model proxy, or by your own provider if you connected one. Agent work needs Docker, or a
346 Linux runner.
347 </span>
348 <span className="mt-3 block max-w-sm">
349 <Input name="agentLabels" defaultValue={settings.agentLabels.join(", ")} disabled={!manage} aria-label="Labels for agent work" />
350 </span>
351 </span>
352 <Switch name="agents" checked={agents} onCheckedChange={setAgents} disabled={!manage} />
353 </label>
354 <label className={`flex items-start justify-between gap-4 rounded-xl border p-4 ${forks ? "border-danger/50 bg-danger/5" : "border-line bg-surface"}`}>
355 <span className="min-w-0">
356 <span className="flex items-center gap-2 text-sm font-medium">
357 <ShieldAlert size={15} className={forks ? "text-danger" : "text-muted"} />
358 Let pull requests from forks use self-hosted runners
359 </span>
360 <span className="mt-1 block text-sm text-muted">
361 Off by default, and best left off. Anyone who can open a pull request could then run any code on your machines, read what they can
362 reach, and leave something behind for the next job. Their jobs never get secrets either way.
363 </span>
364 </span>
365 <Switch name="forks" checked={forks} onCheckedChange={setForks} disabled={!manage} />
366 </label>
367 <p className="text-xs text-faint">
368 g1t&apos;s network guardrails are not enforced on your own machines: a job or an agent there reaches whatever the machine can.
369 </p>
370 {manage && (
371 <div className="flex flex-wrap gap-3">
372 <Button type="submit" disabled={busy}>
373 Save
374 </Button>
375 {scope === "project" && !settings.inherited && (
376 <Button type="submit" name="intent" value="inherit" variant="quiet" disabled={busy}>
377 Follow the workspace
378 </Button>
379 )}
380 </div>
381 )}
382 </Form>
383 );
384}
385
386/** Settings, Runners: a workspace's, or a project's own. */
387export function RunnersPanel({
388 data,
389 action,
390 scope,
391 manage,
392}: {
393 data: RunnersData;
394 action: RunnersAction | undefined;
395 scope: "workspace" | "project";
396 manage: boolean;
397}) {
398 const online = data.runners.filter((runner) => runner.status !== "offline").length;
399 const base = (repo: string) => `/${repo}`;
400 return (
401 <div className="max-w-5xl space-y-8">
402 <div className="min-h-6">
403 {action?.notice && <p className="text-sm text-accent">{action.notice}</p>}
404 <ErrorText>{action?.error ?? data.error ?? null}</ErrorText>
405 </div>
406 <Section
407 icon={<ServerCog size={15} className="text-muted" />}
408 title="Runners"
409 about={
410 <>
411 <p>
412 Your own machines, for jobs with <code className="font-mono text-fg">runs-on: self-hosted</code>. Their time is free on every plan.
413 </p>
414 <p className="mt-2">
415 {data.runners.length === 0 ? "None yet." : `${online} of ${data.runners.length} online.`}
416 </p>
417 </>
418 }
419 >
420 {data.runners.length === 0 ? (
421 <EmptyState title="No self-hosted runners">
422 Add one below. It connects out to g1t; nothing needs to reach the machine.
423 </EmptyState>
424 ) : (
425 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
426 {data.runners.map((runner) => (
427 <RunnerRow key={runner.id} runner={runner} manage={manage && (scope === "workspace" || runner.repo !== null)} base={base} />
428 ))}
429 </ul>
430 )}
431 </Section>
432 {manage && (
433 <Section
434 icon={<Plus size={15} className="text-muted" />}
435 title="New runner"
436 about="Download g1t-runner, register it with a token made here, and start it. Linux, macOS and Windows, x64 and arm64."
437 >
438 <NewRunner token={action?.token ?? null} groups={data.groups} repoScoped={scope === "project"} />
439 </Section>
440 )}
441 {scope === "workspace" && (
442 <Section
443 icon={<Boxes size={15} className="text-muted" />}
444 title="Groups"
445 about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another."
446 >
447 <Groups groups={data.groups} repositories={data.repositories} manage={manage} />
448 </Section>
449 )}
450 <Section
451 icon={<Cpu size={15} className="text-muted" />}
452 title="Where work runs"
453 about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."}
454 >
455 <Settings data={data} manage={manage} scope={scope} />
456 </Section>
457 </div>
458 );
459}