Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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