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

476 lines21,575 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.