Skip to content
148 linesCodeBlameRaw
1import { Laptop } from "lucide-react";
2import { Link } from "react-router";
3
4import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts";
5
6import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, useDesktopRunner } from "../lib/desktop-runner";
7import { EmptyState, TimeAgo } from "./ui";
8import { Badge } from "./ui/badge";
9import { Button } from "./ui/button";
10import { Card } from "./ui/card";
11
12/** A diagnosis's tone, as a dot. */
13const DOT: Record<DesktopDiagnosis["tone"], string> = {
14 ok: "bg-success",
15 working: "bg-info",
16 off: "bg-faint",
17 warning: "bg-warn",
18 problem: "bg-danger",
19};
20
21type Row = {
22 machine: string;
23 person: string | null;
24 backend: DesktopBackend;
25 version: string | null;
26 diagnosis: DesktopDiagnosis;
27 /** This computer's fixes, which only work here. */
28 actions: DesktopRunnerAction[];
29 /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes). */
30 capabilities: DesktopRunnerCapability[];
31 here: boolean;
32 reportedAt: string | null;
33};
34
35/** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */
36export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] {
37 const rows: Row[] = [];
38 if (here && here.workspace === slug) {
39 rows.push({
40 machine: here.machine,
41 person: null,
42 backend: here.backend,
43 version: here.version,
44 diagnosis: here.diagnosis,
45 actions: here.diagnosis.actions,
46 capabilities: here.diagnosis.capabilities ?? [],
47 here: true,
48 reportedAt: null,
49 });
50 }
51 for (const desktop of desktops) {
52 if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue;
53 rows.push({
54 machine: desktop.machine,
55 person: desktop.person,
56 backend: desktop.backend,
57 version: desktop.version,
58 diagnosis: desktop.open ? desktop.diagnosis : desktop.closed,
59 actions: [],
60 capabilities: desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [],
61 here: false,
62 reportedAt: desktop.reportedAt,
63 });
64 }
65 return rows;
66}
67
68/** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */
69function Actions({ actions }: { actions: DesktopRunnerAction[] }) {
70 if (actions.length === 0) return null;
71 return (
72 <div className="mt-2 flex flex-wrap gap-2">
73 {actions.map((action, at) => (
74 <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}>
75 {action.label}
76 </Button>
77 ))}
78 </div>
79 );
80}
81
82/**
83 * The workspace's desktops: each person's computer with the g1t desktop
84 * app, whose runner runs while g1t is open there. What each says, why it
85 * isn't running and the fix, is the app's own words (lib/desktop-runner.ts).
86 */
87export function DesktopRunners({ desktops, slug }: { desktops: DesktopRunner[] | null; slug: string }) {
88 const here = useDesktopRunner();
89 if (!desktops) return <p className="text-sm text-muted">The desktops can&apos;t be read right now.</p>;
90 const rows = desktopRows(desktops, here, slug);
91 if (rows.length === 0) {
92 return (
93 <EmptyState title="No desktops yet">
94 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.
95 Open it signed in as an owner of {slug}.
96 </EmptyState>
97 );
98 }
99 return (
100 <Card asChild divided>
101 <ul>
102 {rows.map((row) => (
103 <li key={row.machine} className="flex items-start gap-3 px-4 py-3">
104 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden />
105 <div className="min-w-0 grow">
106 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
107 <Laptop size={13} className="text-muted" aria-hidden />
108 <span className="font-medium">{row.machine}</span>
109 {row.here && <Badge tone="accent">This computer</Badge>}
110 <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge>
111 {row.person && <span className="text-xs text-faint">@{row.person}</span>}
112 </div>
113 <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p>
114 {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>}
115 {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>}
116 <Actions actions={row.actions} />
117 {row.capabilities.length > 1 && (
118 <ul className="mt-2 space-y-1.5 border-t border-line pt-2">
119 {row.capabilities.map((capability) => (
120 <li key={capability.id} className="flex items-start gap-2">
121 <span className={`mt-1.5 size-1.5 shrink-0 rounded-full ${DOT[capability.tone]}`} aria-hidden />
122 <div className="min-w-0">
123 <p className="text-sm text-fg">
124 {capability.label}: {capability.title}
125 </p>
126 {capability.cause && <p className="text-sm text-muted">{capability.cause}</p>}
127 {capability.fix && <p className="text-sm text-fg-soft">{capability.fix}</p>}
128 <Actions actions={capability.actions} />
129 </div>
130 </li>
131 ))}
132 </ul>
133 )}
134 </div>
135 <div className="shrink-0 text-right text-xs text-faint">
136 {row.version && <div>g1t-runner {row.version}</div>}
137 {row.reportedAt && (
138 <div className="mt-0.5">
139 Heard from <TimeAgo at={row.reportedAt} />
140 </div>
141 )}
142 </div>
143 </li>
144 ))}
145 </ul>
146 </Card>
147 );
148}