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