| 1 | import { Laptop } from "lucide-react"; |
| 2 | import { Link } from "react-router"; |
| 3 | |
| 4 | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts"; |
| 5 | |
| 6 | import { BACKEND_LABEL, type DesktopRunnerAction, desktopRunner, useDesktopRunner } from "../lib/desktop-runner"; |
| 7 | import { EmptyState, TimeAgo } from "./ui"; |
| 8 | import { Badge } from "./ui/badge"; |
| 9 | import { Button } from "./ui/button"; |
| 10 | import { Card } from "./ui/card"; |
| 11 | |
| 12 | /** A diagnosis's tone, as a dot. */ |
| 13 | const 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 | |
| 21 | type 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 | here: boolean; |
| 30 | reportedAt: string | null; |
| 31 | }; |
| 32 | |
| 33 | /** The desktops to list: this computer, live, first; then the others, as their apps last said, or closed. */ |
| 34 | export function desktopRows(desktops: DesktopRunner[], here: ReturnType<typeof useDesktopRunner>, slug: string): Row[] { |
| 35 | const rows: Row[] = []; |
| 36 | if (here && here.workspace === slug) { |
| 37 | rows.push({ |
| 38 | machine: here.machine, |
| 39 | person: null, |
| 40 | backend: here.backend, |
| 41 | version: here.version, |
| 42 | diagnosis: here.diagnosis, |
| 43 | actions: here.diagnosis.actions, |
| 44 | here: true, |
| 45 | reportedAt: null, |
| 46 | }); |
| 47 | } |
| 48 | for (const desktop of desktops) { |
| 49 | if (rows.some((row) => row.here && row.machine.toLowerCase() === desktop.machine.toLowerCase())) continue; |
| 50 | rows.push({ |
| 51 | machine: desktop.machine, |
| 52 | person: desktop.person, |
| 53 | backend: desktop.backend, |
| 54 | version: desktop.version, |
| 55 | diagnosis: desktop.open ? desktop.diagnosis : desktop.closed, |
| 56 | actions: [], |
| 57 | here: false, |
| 58 | reportedAt: desktop.reportedAt, |
| 59 | }); |
| 60 | } |
| 61 | return rows; |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * The workspace's desktops: each person's computer with the g1t desktop |
| 66 | * app, whose runner runs while g1t is open there. What each says, why it |
| 67 | * isn't running and the fix, is the app's own words (lib/desktop-runner.ts). |
| 68 | */ |
| 69 | export function DesktopRunners({ desktops, slug }: { desktops: DesktopRunner[] | null; slug: string }) { |
| 70 | const here = useDesktopRunner(); |
| 71 | if (!desktops) return <p className="text-sm text-muted">The desktops can't be read right now.</p>; |
| 72 | const rows = desktopRows(desktops, here, slug); |
| 73 | if (rows.length === 0) { |
| 74 | return ( |
| 75 | <EmptyState title="No desktops yet"> |
| 76 | The <Link to="/download" className="underline underline-offset-2">g1t desktop app</Link> runs this workspace's self-hosted jobs while it's open, sandboxed, with nothing to set up. |
| 77 | Open it signed in as an owner of {slug}. |
| 78 | </EmptyState> |
| 79 | ); |
| 80 | } |
| 81 | return ( |
| 82 | <Card asChild divided> |
| 83 | <ul> |
| 84 | {rows.map((row) => ( |
| 85 | <li key={row.machine} className="flex items-start gap-3 px-4 py-3"> |
| 86 | <span className={`mt-1.5 size-2 shrink-0 rounded-full ${DOT[row.diagnosis.tone]}`} aria-hidden /> |
| 87 | <div className="min-w-0 grow"> |
| 88 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 89 | <Laptop size={13} className="text-muted" aria-hidden /> |
| 90 | <span className="font-medium">{row.machine}</span> |
| 91 | {row.here && <Badge tone="accent">This computer</Badge>} |
| 92 | <Badge tone={row.backend === "sandboxed" ? "neutral" : "warn"}>{BACKEND_LABEL[row.backend]}</Badge> |
| 93 | {row.person && <span className="text-xs text-faint">@{row.person}</span>} |
| 94 | </div> |
| 95 | <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p> |
| 96 | {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>} |
| 97 | {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>} |
| 98 | {row.actions.length > 0 && ( |
| 99 | <div className="mt-2 flex flex-wrap gap-2"> |
| 100 | {row.actions.map((action, at) => ( |
| 101 | <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}> |
| 102 | {action.label} |
| 103 | </Button> |
| 104 | ))} |
| 105 | </div> |
| 106 | )} |
| 107 | </div> |
| 108 | <div className="shrink-0 text-right text-xs text-faint"> |
| 109 | {row.version && <div>g1t-runner {row.version}</div>} |
| 110 | {row.reportedAt && ( |
| 111 | <div className="mt-0.5"> |
| 112 | Heard from <TimeAgo at={row.reportedAt} /> |
| 113 | </div> |
| 114 | )} |
| 115 | </div> |
| 116 | </li> |
| 117 | ))} |
| 118 | </ul> |
| 119 | </Card> |
| 120 | ); |
| 121 | } |