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.

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