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