Skip to content
441 linesCodeBlameRaw
1import { AppWindow, Laptop, SquareTerminal } from "lucide-react";
2import { type ReactNode, useState } from "react";
3import { Link } from "react-router";
4
5import type { DesktopBackend, DesktopDiagnosis, DesktopRunner, DesktopUpdate, Runner } from "@g1t/contracts";
6
7import { useBusy } from "../lib/busy";
8import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, type DesktopRunnerView, desktopRunner, jobHref, kindStatusKey, runnerStatusKey, useDesktopRunner } from "../lib/desktop-runner";
9import { EmptyState, TimeAgo } from "./ui";
10import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog";
11import { Badge, type BadgeTone } from "./ui/badge";
12import { Button } from "./ui/button";
13import { Card } from "./ui/card";
14import { Progress } from "./ui/progress";
15import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
16
17/** A diagnosis's tone, as a dot. */
18export const DOT: Record<DesktopDiagnosis["tone"], string> = {
19 ok: "bg-success",
20 working: "bg-info",
21 off: "bg-faint",
22 warning: "bg-warn",
23 problem: "bg-danger",
24};
25
26/** A kind of job's tone, as its label's. */
27export const BADGE: Record<DesktopDiagnosis["tone"], BadgeTone> = {
28 ok: "success",
29 working: "info",
30 off: "neutral",
31 warning: "warn",
32 problem: "danger",
33};
34
35/** Each kind of job's icon: a terminal for Linux jobs, a window for Windows jobs, a laptop for macOS jobs. */
36export const ICON: Record<string, ReactNode> = {
37 linux: <SquareTerminal size={15} aria-hidden />,
38 windows: <AppWindow size={15} aria-hidden />,
39 macos: <Laptop size={15} aria-hidden />,
40};
41
42/** How a kind of job keeps each job from the computer, and what that means. */
43const ISOLATION: Record<string, { label: string; hint: string }> = {
44 "nested-container": { label: "Fresh container in its VM", hint: "Each job runs in a fresh container inside the runner's own Linux VM, and is removed after." },
45 vm: { label: "A VM of its own", hint: "Each job runs in a VM of its own, such as a Windows Sandbox, discarded after." },
46};
47
48type Row = {
49 machine: string;
50 person: string | null;
51 backend: DesktopBackend;
52 version: string | null;
53 diagnosis: DesktopDiagnosis;
54 /** This computer's fixes, which only work here. */
55 actions: DesktopRunnerAction[];
56 /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes and Start or Stop). */
57 capabilities: DesktopRunnerCapability[];
58 /** The kinds of job say it all: the whole runner's line isn't shown as well. */
59 inCapabilities: boolean;
60 here: boolean;
61 reportedAt: string | null;
62 /** How its runner's updates stand, while g1t is open there. */
63 update: DesktopUpdate | null;
64 /** Here only: the app's button that looks for a newer runner now. */
65 check: DesktopRunnerAction | null;
66 /** Here only: the app is looking for a newer runner now. */
67 checking: boolean;
68};
69
70/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
71export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
72 const rows: Row[] = [];
73 if (here && here.workspace === slug) {
74 const capabilities = here.diagnosis.capabilities ?? [];
75 rows.push({
76 machine: here.machine,
77 person: null,
78 backend: here.backend,
79 version: here.version,
80 diagnosis: here.diagnosis,
81 actions: here.diagnosis.actions,
82 capabilities,
83 inCapabilities: !!here.diagnosis.in_capabilities && capabilities.length > 0,
84 here: true,
85 reportedAt: null,
86 update: here.diagnosis.update ?? null,
87 check: here.diagnosis.update?.check ?? null,
88 checking: !!here.diagnosis.update?.checking,
89 });
90 }
91 for (const desktop of desktops) {
92 if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
93 const capabilities = desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [];
94 rows.push({
95 machine: desktop.machine,
96 person: desktop.person,
97 backend: desktop.backend,
98 version: desktop.version,
99 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
100 actions: [],
101 capabilities,
102 inCapabilities: desktop.open && !!desktop.diagnosis.in_capabilities && capabilities.length > 0,
103 here: false,
104 reportedAt: desktop.reportedAt,
105 update: desktop.open ? (desktop.diagnosis.update ?? null) : null,
106 check: null,
107 checking: false,
108 });
109 }
110 return rows;
111}
112
113/**
114 * A diagnosis's fixes, as buttons: they work only on this computer, in the
115 * app. Each is busy from its click until the status it waits on changes
116 * (`status`, lib/busy.ts); one the app asks about first asks.
117 */
118export function Actions({
119 actions,
120 primary = true,
121 status,
122 working = false,
123 onError,
124}: {
125 actions: DesktopRunnerAction[];
126 primary?: boolean;
127 /** What a press waits on: the runner's status (`runnerStatusKey`, `kindStatusKey`). */
128 status: string;
129 /** Busy for a reason of the app's own, such as a check already under way. */
130 working?: boolean;
131 onError?: (error: unknown) => void;
132}) {
133 if (actions.length === 0) return null;
134 return (
135 <>
136 {actions.map((action, at) => (
137 <ActionButton key={action.id} action={action} variant={primary && at === 0 ? "secondary" : "outline"} status={status} working={working} onError={onError} />
138 ))}
139 </>
140 );
141}
142
143function ActionButton({
144 action,
145 variant,
146 status,
147 working,
148 onError,
149}: {
150 action: DesktopRunnerAction;
151 variant: "secondary" | "outline";
152 status: string;
153 working: boolean;
154 onError?: (error: unknown) => void;
155}) {
156 const { busy, run } = useBusy(status, onError);
157 const [asking, setAsking] = useState(false);
158 const go = () => run(() => desktopRunner()?.act(action.id), !!action.waits);
159 // The app's question, as a title and what it means: "Remove this computer's sandbox?" and the rest.
160 const [title, ...rest] = (action.confirm ?? "").split(/(?<=\?)\s+/);
161 return (
162 <>
163 <Button size="xs" variant={variant} busy={busy || working} onClick={() => (action.confirm ? setAsking(true) : go())}>
164 {action.label}
165 </Button>
166 {action.confirm && (
167 <AlertDialog open={asking} onOpenChange={setAsking}>
168 <AlertDialogContent>
169 <AlertDialogHeader>
170 <AlertDialogTitle>{title}</AlertDialogTitle>
171 {rest.length > 0 && <AlertDialogDescription>{rest.join(" ")}</AlertDialogDescription>}
172 </AlertDialogHeader>
173 <AlertDialogFooter>
174 <AlertDialogCancel>Cancel</AlertDialogCancel>
175 <AlertDialogAction onClick={go}>{action.label}</AlertDialogAction>
176 </AlertDialogFooter>
177 </AlertDialogContent>
178 </AlertDialog>
179 )}
180 </>
181 );
182}
183
184/**
185 * Start or Stop for one kind of job on this computer, which leaves the
186 * others as they are: busy from the click until its status says it
187 * started or stopped.
188 */
189export function Toggle({ capability, status, onError }: { capability: DesktopRunnerCapability; status: string; onError?: (error: unknown) => void }) {
190 const { busy, run } = useBusy(status, onError);
191 const on = capability.enabled !== false;
192 return (
193 <Button
194 size="xs"
195 variant={on ? "outline" : "soft"}
196 className="ml-auto"
197 busy={busy}
198 aria-label={`${on ? "Stop" : "Start"} ${capability.label}`}
199 onClick={() => run(() => desktopRunner()?.act(on ? "stop" : "start", capability.id))}
200 >
201 {on ? "Stop" : "Start"}
202 </Button>
203 );
204}
205
206/**
207 * A button that asks the runner for something by its id: busy until the
208 * app has it (a page, a window, the log), or with `waits` until the
209 * status that follows changes.
210 */
211export function ActButton({
212 action,
213 label,
214 status,
215 fail,
216 waits = false,
217}: {
218 action: string;
219 label: string;
220 status: string;
221 fail?: (error: unknown) => void;
222 waits?: boolean;
223}) {
224 const { busy, run } = useBusy(status, fail);
225 return (
226 <Button size="xs" variant="outline" busy={busy} onClick={() => run(() => desktopRunner()?.act(action), waits)}>
227 {label}
228 </Button>
229 );
230}
231
232function Detail({ term, children }: { term: ReactNode; children: ReactNode }) {
233 return (
234 <div className="flex min-w-0 items-baseline gap-3">
235 <dt className="w-16 shrink-0 text-faint">{term}</dt>
236 <dd className="min-w-0 break-words text-fg-soft">{children}</dd>
237 </div>
238 );
239}
240
241/** When the app last looked for a newer runner: the app sends when, and this says how long ago, as "Heard from" does. */
242function Checked({ update }: { update: DesktopUpdate | null }) {
243 if (!update?.checked_at) return null;
244 return (
245 <span className="block">
246 Checked <TimeAgo at={update.checked_at} />
247 </span>
248 );
249}
250
251/** One kind of job, as a card of its own: its state, what it runs, why it doesn't and the fix, and here its own Start or Stop. */
252export function KindCard({
253 capability,
254 here,
255 runners,
256 update,
257 view = null,
258 onError,
259}: {
260 capability: DesktopRunnerCapability;
261 here: boolean;
262 runners: Runner[];
263 update: DesktopUpdate | null;
264 /** Here: the runner as the app says it now, which its buttons wait on. */
265 view?: DesktopRunnerView | null;
266 onError?: (error: unknown) => void;
267}) {
268 const href = jobHref(capability, runners);
269 const isolation = capability.isolation ? (ISOLATION[capability.isolation] ?? { label: capability.isolation, hint: null }) : null;
270 const edge = capability.tone === "problem" ? "border-danger/40" : capability.tone === "warning" ? "border-warn/35" : "";
271 const live = capability.state === "busy" || capability.state === "starting" || capability.state === "downloading" || capability.state === "updating";
272 const fixes = here ? capability.actions : [];
273 const toggle = here && capability.can_toggle;
274 return (
275 <Card tone="plain" radius="lg" className={`flex min-w-0 flex-col gap-3 p-4 ${edge}`} aria-label={capability.label} role="group">
276 <div className="flex items-center gap-3">
277 <span className={`grid size-9 shrink-0 place-items-center rounded-lg bg-raised ${capability.enabled === false ? "text-faint" : "text-fg-soft"}`}>
278 {ICON[capability.id] ?? ICON.linux}
279 </span>
280 <div className="min-w-0">
281 <p className="text-sm font-medium">{capability.label}</p>
282 <Badge tone={BADGE[capability.tone]} className="mt-1" role={here ? "status" : undefined}>
283 <span className={`size-1.5 rounded-full bg-current ${live ? "motion-safe:animate-pulse" : ""}`} aria-hidden />
284 {capability.title}
285 </Badge>
286 </div>
287 </div>
288 {capability.about && <p className="text-sm text-muted">{capability.about}</p>}
289 {capability.job && (
290 <div className="rounded-lg bg-raised px-3 py-2 text-sm">
291 <span className="block text-[0.6875rem] font-medium tracking-wide text-muted uppercase">{capability.job.kind === "agent" ? "Sandbox run" : "Running"}</span>
292 {href ? (
293 <Link to={href} className="font-medium text-fg underline-offset-4 hover:underline">
294 {capability.job.name}
295 </Link>
296 ) : (
297 <span className="font-medium text-fg">{capability.job.name}</span>
298 )}
299 <span className="text-muted"> in {capability.job.repo}</span>
300 </div>
301 )}
302 {capability.state === "downloading" && typeof capability.progress === "number" && (
303 <div className="flex items-center gap-3">
304 <Progress value={capability.progress} aria-label={`${capability.label}: the sandbox's image`} className="h-1.5" />
305 <span className="w-9 shrink-0 text-right text-xs text-muted tabular-nums">{capability.progress}%</span>
306 </div>
307 )}
308 {(capability.headline || capability.cause || capability.fix) && (
309 <div className="space-y-1 text-sm">
310 {capability.headline && <p className="font-medium text-fg">{capability.headline}</p>}
311 {capability.cause && <p className="text-muted">{capability.cause}</p>}
312 {capability.fix && <p className="text-fg-soft">{capability.fix}</p>}
313 </div>
314 )}
315 {(capability.runner || capability.version || isolation) && (
316 <dl className="space-y-0.5 border-t border-line pt-2 text-xs">
317 {capability.runner && <Detail term="On g1t">{capability.runner}</Detail>}
318 {capability.version && <Detail term="Version">g1t-runner {capability.version}</Detail>}
319 {(capability.update || (capability.version && update?.checked_at)) && (
320 <Detail term="Updates">
321 {capability.update && <span className="block text-accent">{capability.update}</span>}
322 <Checked update={update} />
323 </Detail>
324 )}
325 {isolation && (
326 <Detail term="Isolation">
327 {isolation.hint ? (
328 <Tooltip>
329 <TooltipTrigger asChild>
330 <span tabIndex={0} className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">
331 {isolation.label}
332 </span>
333 </TooltipTrigger>
334 <TooltipContent>{isolation.hint}</TooltipContent>
335 </Tooltip>
336 ) : (
337 isolation.label
338 )}
339 </Detail>
340 )}
341 </dl>
342 )}
343 {(fixes.length > 0 || toggle) && (
344 <div className="mt-auto flex flex-wrap items-center gap-2">
345 <Actions
346 actions={fixes}
347 primary={capability.tone === "problem" || (capability.tone === "warning" && capability.state !== "waiting")}
348 status={kindStatusKey(capability, view)}
349 onError={onError}
350 />
351 {toggle && <Toggle capability={capability} status={kindStatusKey(capability, view)} onError={onError} />}
352 </div>
353 )}
354 </Card>
355 );
356}
357
358/**
359 * The workspace's desktops: each person's computer with the g1t desktop
360 * app, whose runner runs while g1t is open there. What each says, why it
361 * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
362 * A sandboxed one's kinds of job, Linux and Windows, are cards of their own:
363 * one can run while the other can't, or is stopped; on this computer, each
364 * has its own Start or Stop.
365 */
366export function DesktopRunners({ desktops, slug, runners = [] }: { desktops: DesktopRunner[] | null; slug: string; runners?: Runner[] }) {
367 const here = useDesktopRunner();
368 if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
369 const rows = desktopRows(desktops, here, slug);
370 if (rows.length === 0) {
371 return (
372 <EmptyState title="No desktops yet">
373 The <Link to="/download" className="underline underline-offset-2">g1t desktop app</Link> runs this workspace&apos;s self-hosted jobs while it&apos;s open, sandboxed, with nothing to set up.
374 Open it signed in as an owner of {slug}.
375 </EmptyState>
376 );
377 }
378 return (
379 <Card asChild divided>
380 <ul>
381 {rows.map((row) => (
382 <li key={row.machine} className="space-y-3 px-4 py-4">
383 <div className="flex items-start gap-3">
384 <div className="min-w-0 grow">
385 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
386 <Laptop size={13} className="text-muted" aria-hidden />
387 <span className="font-medium">{row.machine}</span>
388 {row.here && <Badge tone="accent">This computer</Badge>}
389 <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
390 {row.person && <span className="text-xs text-faint">@{row.person}</span>}
391 </div>
392 </div>
393 <div className="shrink-0 text-right text-xs text-faint">
394 {row.version && <div>g1t-runner {row.version}</div>}
395 {/* With no cards to say it, what becomes of a newer runner, and when the app last looked. */}
396 {row.capabilities.length === 0 && row.update?.note && <div className="mt-0.5 text-accent">{row.update.note}</div>}
397 {row.capabilities.length === 0 && row.update?.checked_at && (
398 <div className="mt-0.5">
399 <Checked update={row.update} />
400 </div>
401 )}
402 {row.reportedAt && (
403 <div className="mt-0.5">
404 Heard from <TimeAgo at={row.reportedAt} />
405 </div>
406 )}
407 {row.check && (
408 <div className="mt-2">
409 <Actions actions={[row.check]} primary={false} status={here ? runnerStatusKey(here) : ""} working={row.checking} />
410 </div>
411 )}
412 </div>
413 </div>
414 {!row.inCapabilities && (
415 <div className="flex items-start gap-3" role={row.here ? "status" : undefined}>
416 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
417 <div className="min-w-0">
418 <p className="text-sm text-fg">{row.diagnosis.title}</p>
419 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
420 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
421 {row.actions.length > 0 && (
422 <div className="mt-2 flex flex-wrap gap-2">
423 <Actions actions={row.actions} status={here ? runnerStatusKey(here) : ""} />
424 </div>
425 )}
426 </div>
427 </div>
428 )}
429 {row.capabilities.length > 0 && (
430 <div className={`grid gap-3 ${row.capabilities.length > 1 ? "sm:grid-cols-2" : ""}`}>
431 {row.capabilities.map((capability) => (
432 <KindCard key={capability.id} capability={capability} here={row.here} runners={runners} update={row.update} view={row.here ? here : null} />
433 ))}
434 </div>
435 )}
436 </li>
437 ))}
438 </ul>
439 </Card>
440 );
441}