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
388 linesCodeBlameRaw
1import { ArrowRight, Bot, Cloud, Laptop, Plus, ServerCog, Workflow } from "lucide-react";
2import type { ReactNode } from "react";
3import { Link } from "react-router";
4
5import { RUN_KIND_LABEL, type Runner, type RunKind } from "@g1t/contracts";
6
7import { money } from "../lib/money";
8import { type CloudWork, machineTime, perMinute } from "../lib/runners";
9import type { RunnersAction, RunnersPageData } from "../lib/runners.server";
10import { DesktopRunners } from "./desktop-runners";
11import { Groups, NewRunner, RunnerRow, RunnerSettingsForm, STATUS } from "./runners";
12import { EmptyState, ErrorText, TimeAgo } from "./ui";
13import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
14
15/** The most of what runs on g1t's cloud the card lists before saying how many more. */
16const CLOUD_SHOWN = 6;
17
18function Section({ id, title, about, action, children }: { id?: string; title: string; about?: ReactNode; action?: ReactNode; children: ReactNode }) {
19 return (
20 <section id={id} className="scroll-mt-6 space-y-3">
21 <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-1">
22 <div className="min-w-0">
23 <h2 className="text-base font-semibold tracking-tight">{title}</h2>
24 {about && <div className="mt-0.5 text-sm text-muted">{about}</div>}
25 </div>
26 {action}
27 </div>
28 {children}
29 </section>
30 );
31}
32
33/** One figure in a place's card: a label and its value. */
34function Figure({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
35 return (
36 <div className="flex items-baseline justify-between gap-3 py-2 text-sm">
37 <dt className="text-muted">
38 {hint ? (
39 <Tooltip>
40 <TooltipTrigger asChild>
41 <span className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">{label}</span>
42 </TooltipTrigger>
43 <TooltipContent>{hint}</TooltipContent>
44 </Tooltip>
45 ) : (
46 label
47 )}
48 </dt>
49 <dd className="min-w-0 text-right text-fg tabular-nums">{children}</dd>
50 </div>
51 );
52}
53
54function Unknown({ what }: { what: string }) {
55 return <span className="text-faint">{what} didn&apos;t answer</span>;
56}
57
58function PlaceCard({ icon, title, tag, about, headline, children }: { icon: ReactNode; title: string; tag: ReactNode; about: string; headline: ReactNode; children: ReactNode }) {
59 return (
60 <section className="flex min-w-0 flex-col rounded-2xl border border-line bg-surface">
61 <header className="px-5 pt-4">
62 <div className="flex items-center gap-2">
63 <span className="grid size-7 shrink-0 place-items-center rounded-lg bg-raised text-fg-soft">{icon}</span>
64 <h2 className="text-sm font-semibold">{title}</h2>
65 <span className="ml-auto">{tag}</span>
66 </div>
67 <p className="mt-2 text-sm leading-relaxed text-muted">{about}</p>
68 </header>
69 <div className="px-5 pt-4">{headline}</div>
70 <dl className="mt-2 divide-y divide-line border-t border-line px-5 py-1">{children}</dl>
71 </section>
72 );
73}
74
75function kindLabel(work: CloudWork): string {
76 if (work.kind === "workflow") return "Workflow job";
77 return RUN_KIND_LABEL[work.detail as RunKind] ?? "Sandbox run";
78}
79
80function CloudList({ data }: { data: RunnersPageData }) {
81 const cloud = data.cloud;
82 if (!cloud) return <p className="px-5 py-4 text-sm text-muted">What runs on g1t&apos;s cloud can&apos;t be read right now.</p>;
83 if (cloud.running.length === 0) {
84 return (
85 <p className="px-5 py-4 text-sm text-muted">
86 Nothing is running here now. A sandbox starts for each run or job and stops when it ends{cloud.queued > 0 ? `; ${cloud.queued} waiting to start` : ""}.
87 </p>
88 );
89 }
90 const shown = cloud.running.slice(0, CLOUD_SHOWN);
91 const rest = cloud.running.length - shown.length;
92 return (
93 <ul className="divide-y divide-line">
94 {shown.map((work) => (
95 <li key={`${work.kind}:${work.id}`} className="flex items-center gap-3 px-5 py-2.5">
96 <span className="grid size-6 shrink-0 place-items-center rounded-md bg-info/12 text-info" aria-hidden>
97 {work.kind === "agent" ? <Bot size={13} /> : <Workflow size={13} />}
98 </span>
99 <div className="min-w-0 grow">
100 <Link to={work.href} className="block truncate text-sm text-fg hover:underline">
101 {work.title}
102 </Link>
103 <p className="truncate text-xs text-muted">
104 {kindLabel(work)} · {work.repo}
105 </p>
106 </div>
107 {work.startedAt && (
108 <span className="shrink-0 text-xs text-faint">
109 <TimeAgo at={work.startedAt} />
110 </span>
111 )}
112 </li>
113 ))}
114 {(rest > 0 || cloud.more || cloud.queued > 0) && (
115 <li className="px-5 py-2.5 text-xs text-muted">
116 {[rest > 0 || cloud.more ? `${rest > 0 ? rest : "More"}${cloud.more ? "+" : ""} more running` : null, cloud.queued > 0 ? `${cloud.queued} waiting to start` : null]
117 .filter(Boolean)
118 .join(" · ")}
119 </li>
120 )}
121 </ul>
122 );
123}
124
125function StatusCounts({ runners }: { runners: Runner[] }) {
126 const counts = { busy: 0, online: 0, offline: 0 } as Record<Runner["status"], number>;
127 for (const runner of runners) counts[runner.status] += 1;
128 return (
129 <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
130 {(["busy", "online", "offline"] as const).map((status) => (
131 <li key={status} className="flex items-center gap-1.5">
132 <span className={`size-2 rounded-full ${STATUS[status].dot}`} aria-hidden />
133 <span className="text-fg tabular-nums">{counts[status]}</span> {STATUS[status].label.toLowerCase()}
134 </li>
135 ))}
136 </ul>
137 );
138}
139
140/** The workspace's Runners page: g1t's cloud and its own runners, side by side. */
141export function RunnersPage({ data, action, slug }: { data: RunnersPageData; action: RunnersAction | undefined; slug: string }) {
142 const own = data.runners;
143 const online = own.filter((runner) => runner.status !== "offline").length;
144 const cost = data.cost;
145 const running = data.cloud?.running.length ?? null;
146 const usage = `/${slug}/-/usage`;
147 return (
148 <div className="space-y-10">
149 <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
150 <div className="min-w-0">
151 <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Runners</h1>
152 <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
153 The machines your workflow jobs, checks and merge queue run on. g1t&apos;s cloud needs nothing set up; your own runners are any machine with{" "}
154 <code className="font-mono text-fg-soft">g1t-runner</code> on it, and time on them is free.
155 </p>
156 </div>
157 <a
158 href="#add"
159 className="inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-lg bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"
160 >
161 <Plus size={15} />
162 Add a runner
163 </a>
164 </header>
165
166 {(action?.notice || action?.error || data.error) && (
167 <div>
168 {action?.notice && <p className="text-sm text-success">{action.notice}</p>}
169 <ErrorText>{action?.error ?? data.error ?? null}</ErrorText>
170 </div>
171 )}
172
173 <div className="grid items-start gap-5 lg:grid-cols-2">
174 <PlaceCard
175 icon={<Cloud size={15} />}
176 title="g1t cloud"
177 tag={<span className="rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft">Managed by g1t</span>}
178 about="A fresh sandbox for every run and workflow job, stopped when it ends. Billed by the second at the price below."
179 headline={
180 <div className="flex items-baseline gap-2">
181 <span className="text-3xl font-semibold tracking-tight tabular-nums">{running ?? "–"}</span>
182 <span className="text-sm text-muted">running now</span>
183 </div>
184 }
185 >
186 <Figure label="This month" hint="Runner minutes on your usage, at price, from the first of the month. Each job's time is rounded up to the minute.">
187 {cost ? (
188 <>
189 {machineTime(cost.cloudSeconds)} · {money(cost.cloudMicros)}
190 </>
191 ) : (
192 <Unknown what="Billing" />
193 )}
194 </Figure>
195 <Figure label="Price" hint={`A minute of each machine: ${data.prices.map((price) => price.title).join("; ")}.`}>
196 <span className="flex flex-wrap justify-end gap-x-2">
197 {data.prices.map((price) => (
198 <span key={price.machine} className="whitespace-nowrap">
199 {price.machine} {perMinute(price.perMinuteMicros)}/min
200 </span>
201 ))}
202 </span>
203 </Figure>
204 </PlaceCard>
205
206 <PlaceCard
207 icon={<ServerCog size={15} />}
208 title="Your runners"
209 tag={<span className="rounded-full bg-success/12 px-2 py-0.5 text-xs font-medium text-success">Free</span>}
210 about="Your own machines: Linux, macOS or Windows, anywhere they can reach g1t. They only connect out, and take the work whose labels they have."
211 headline={
212 <div className="space-y-2">
213 <div className="flex items-baseline gap-2">
214 <span className="text-3xl font-semibold tracking-tight tabular-nums">{own.length === 0 ? 0 : online}</span>
215 <span className="text-sm text-muted">{own.length === 0 ? "runners yet" : `of ${own.length} online`}</span>
216 </div>
217 {own.length > 0 && <StatusCounts runners={own} />}
218 </div>
219 }
220 >
221 <Figure label="This month" hint="Self-hosted runner time on your usage, from the first of the month. It is never charged.">
222 {cost ? (
223 <>
224 {machineTime(cost.ownSeconds)} · {money(0)}
225 </>
226 ) : (
227 <Unknown what="Billing" />
228 )}
229 </Figure>
230 <Figure label="Waiting for one of them">
231 {data.waiting ? (
232 data.waiting.jobs + data.waiting.tasks === 0 ? (
233 "Nothing"
234 ) : (
235 [
236 data.waiting.jobs > 0 ? `${data.waiting.jobs} ${data.waiting.jobs === 1 ? "job" : "jobs"}` : null,
237 data.waiting.tasks > 0 ? `${data.waiting.tasks} sandbox ${data.waiting.tasks === 1 ? "run" : "runs"}` : null,
238 ]
239 .filter(Boolean)
240 .join(", ")
241 )
242 ) : (
243 <Unknown what="Actions" />
244 )}
245 </Figure>
246 <Figure label="Takes sandbox runs">
247 {data.settings ? (data.settings.agentsOnSelfHosted ? `Yes, labelled ${data.settings.agentLabels.join(", ")}` : "No") : "–"}
248 </Figure>
249 </PlaceCard>
250 </div>
251
252 <div className="grid items-start gap-5 lg:grid-cols-2">
253 <Section title="On g1t cloud now" about="Sandbox runs and workflow jobs in g1t's sandboxes.">
254 <div className="rounded-2xl border border-line bg-surface">
255 <CloudList data={data} />
256 </div>
257 </Section>
258 <Section
259 title="Machine time"
260 about={cost ? `${cost.from} to ${cost.until}, as your usage shows it.` : "From your usage."}
261 action={
262 <Link to={usage} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
263 Usage
264 <ArrowRight size={12} />
265 </Link>
266 }
267 >
268 <TimeSplit data={data} />
269 </Section>
270 </div>
271
272 <Section title="Your runners" about={own.length === 0 ? "None yet." : `${online} of ${own.length} online. A runner that hasn't been heard from for 90 seconds is offline.`}>
273 {own.length === 0 ? (
274 <EmptyState title="No runners of your own yet">
275 <a href="#add" className="underline underline-offset-2">
276 Add one
277 </a>{" "}
278 to run jobs on your hardware. It connects out to g1t; nothing needs to reach the machine.
279 </EmptyState>
280 ) : (
281 <ul className="divide-y divide-line rounded-2xl border border-line bg-surface">
282 {own.map((runner) => (
283 <RunnerRow key={runner.id} runner={runner} manage />
284 ))}
285 </ul>
286 )}
287 </Section>
288
289 <Section
290 id="desktops"
291 title="Desktops"
292 about="Computers with the g1t desktop app, whose runner runs while g1t is open there: what each is doing, and when it isn't running, why and the fix."
293 >
294 <DesktopRunners desktops={data.desktops} slug={slug} runners={own} />
295 </Section>
296
297 <Section title="Where work runs" about="For every project in the workspace, unless a project says otherwise in its own settings.">
298 <div className="grid gap-3 md:grid-cols-3">
299 <WhereCard icon={<Workflow size={15} />} title="Workflow jobs">
300 Each job&apos;s <code className="font-mono text-fg-soft">runs-on</code> decides. <code className="font-mono text-fg-soft">ubuntu-latest</code>,{" "}
301 <code className="font-mono text-fg-soft">g1t-4core</code> and other Linux labels run on g1t cloud;{" "}
302 <code className="font-mono text-fg-soft">self-hosted</code> and your own labels run on your runners.
303 </WhereCard>
304 <WhereCard icon={<Bot size={15} />} title="Checks and the merge queue">
305 {data.settings?.agentsOnSelfHosted
306 ? `Checks, merge checks and the merge queue run on your runners labelled ${data.settings.agentLabels.join(", ")}.`
307 : "Checks, merge checks and the merge queue run on g1t cloud. Switch them to your runners below."}
308 </WhereCard>
309 <WhereCard icon={<Laptop size={15} />} title="Your desktop">
310 The g1t desktop app runs a runner while it&apos;s open, sandboxed and labelled <code className="font-mono text-fg-soft">desktop</code>, for jobs that ask for{" "}
311 <code className="font-mono text-fg-soft">self-hosted</code>. Each desktop is under <a href="#desktops" className="underline underline-offset-2">Desktops</a>.
312 </WhereCard>
313 </div>
314 <RunnerSettingsForm
315 key={data.settings ? `${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"}
316 data={data}
317 manage
318 scope="workspace"
319 />
320 </Section>
321
322 <Section
323 id="add"
324 title="Add a runner"
325 about="Download g1t-runner, register it with a token made here, and start it. Linux, macOS and Windows, x64 and arm64, or Docker."
326 >
327 <NewRunner token={action?.token ?? null} groups={data.groups} repoScoped={false} />
328 </Section>
329
330 <Section title="Groups" about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another.">
331 <Groups groups={data.groups} repositories={data.repositories} manage action={action} />
332 </Section>
333
334 </div>
335 );
336}
337
338function WhereCard({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
339 return (
340 <div className="rounded-2xl border border-line bg-surface p-4">
341 <h3 className="flex items-center gap-2 text-sm font-medium">
342 <span className="text-muted">{icon}</span>
343 {title}
344 </h3>
345 <p className="mt-2 text-sm leading-relaxed text-muted">{children}</p>
346 </div>
347 );
348}
349
350/** This month's machine time, g1t cloud's against the workspace's own, as one bar. */
351function TimeSplit({ data }: { data: RunnersPageData }) {
352 const cost = data.cost;
353 if (!cost) {
354 return (
355 <div className="rounded-2xl border border-line bg-surface px-5 py-4 text-sm text-muted">Billing didn&apos;t answer, so this month&apos;s time can&apos;t be shown right now.</div>
356 );
357 }
358 const total = cost.cloudSeconds + cost.ownSeconds;
359 return (
360 <div className="rounded-2xl border border-line bg-surface px-5 py-4">
361 {total === 0 ? (
362 <p className="text-sm text-muted">No machine time yet this month.</p>
363 ) : (
364 <div className="flex h-2.5 gap-0.5 overflow-hidden rounded-full" role="img" aria-label="This month's machine time by where it ran">
365 {cost.cloudSeconds > 0 && <span className="h-full min-w-0.75 bg-info" style={{ flexGrow: cost.cloudSeconds }} />}
366 {cost.ownSeconds > 0 && <span className="h-full min-w-0.75 bg-success" style={{ flexGrow: cost.ownSeconds }} />}
367 </div>
368 )}
369 <ul className="mt-3 space-y-2 text-sm">
370 <li className="flex items-center gap-2">
371 <span className="size-2 shrink-0 rounded-xs bg-info" aria-hidden />
372 <span className="min-w-0 grow truncate text-fg-soft">g1t cloud</span>
373 <span className="text-muted tabular-nums">{machineTime(cost.cloudSeconds)}</span>
374 <span className="w-20 text-right text-fg tabular-nums">{money(cost.cloudMicros)}</span>
375 </li>
376 <li className="flex items-center gap-2">
377 <span className="size-2 shrink-0 rounded-xs bg-success" aria-hidden />
378 <span className="min-w-0 grow truncate text-fg-soft">Your runners</span>
379 <span className="text-muted tabular-nums">{machineTime(cost.ownSeconds)}</span>
380 <span className="w-20 text-right text-fg tabular-nums">{money(0)}</span>
381 </li>
382 </ul>
383 <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">
384 {cost.free ? "Usage at cost: g1t charges nothing for it now." : "At price, before your plan's included usage or credit paid for any of it."} Models are separate.
385 </p>
386 </div>
387 );
388}