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