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