diff --git a/README.md b/README.md index eb13303..48304f7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ bb plugin install git:https://github.com/MGrin/bb-plugin-system.git@main ## What it gives you **System panel** — CPU, memory and disk tiles with status-toned meters, sparklines for -the last hour, and top-process tables by CPU and by memory. +the last hour, and top-process tables by CPU and by memory. A machine picker switches +the whole dashboard between the primary bb machine and any connected enrolled machine. **Homepage tiles** — a compact CPU / memory / disk row. @@ -22,14 +23,17 @@ bb system top top processes by CPU and memory bb system history [m] compact trend for the last N minutes (default 60) ``` -Unlike a menu-bar monitor, this keeps **history**: a background sampler writes to a 24-hour -ring buffer, so you can answer "what was this machine doing twenty minutes ago, while that -fleet was running?" — which is the question that actually comes up. +Unlike a menu-bar monitor, this keeps **per-machine history**: a background sampler writes +to a 24-hour ring buffer, so you can answer "what was this machine doing twenty minutes ago, +while that fleet was running?" — which is the question that actually comes up. ## How it measures -Metrics come from [`systeminformation`](https://www.npmjs.com/package/systeminformation) -(MIT, zero dependencies), which matters more than it sounds: +Metrics on the primary machine come from +[`systeminformation`](https://www.npmjs.com/package/systeminformation) (MIT, zero +dependencies). Connected machines are sampled through their bb daemon with native macOS +or Linux tools, so the plugin server never mistakes its own filesystem and processes for +the selected machine's. - **Memory** uses Activity Monitor's basis (`anonymous - purgeable + wired + compressed`). Summing `Pages active + wired + compressor` — the obvious `vm_stat` reading — counts @@ -48,8 +52,8 @@ the plugin uses that. Top-process tables still shell `ps` directly: systeminformation's darwin implementation runs the same command with more columns and takes its decayed `pcpu` verbatim. -macOS is the tested platform; the metric library covers Linux and Windows, but the -pressure reading and process table are macOS-specific. +macOS is the tested platform. Remote sampling also supports Linux; Windows machines remain +listed but cannot currently be sampled. See also [get-bb/bb#1171](https://github.com/get-bb/bb/pull/1171), an open proposal for an official System Monitor plugin. diff --git a/app.tsx b/app.tsx index 2fb966c..48d122a 100644 --- a/app.tsx +++ b/app.tsx @@ -1,6 +1,18 @@ // bb-plugin-system frontend — System panel + homepage tiles. -import { useEffect, useState } from "react"; -import { definePluginApp, useRealtime, useRpc } from "@bb/plugin-sdk/app"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + definePluginApp, + useRealtime, + useRealtimeConnectionState, + useRpc, +} from "@bb/plugin-sdk/app"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./components/ui/select"; import type { rpcContract } from "./server"; type Sample = { @@ -13,8 +25,16 @@ type Current = { topCpu: { pid: number; cpu: number; memMb: number; command: string }[]; topMem: { pid: number; cpu: number; memMb: number; command: string }[]; uptime: string; + stale: boolean; + error: string | null; }; type Hist = { samples: Sample[] }; +type Machine = { + id: string; + name: string; + status: "connected" | "disconnected"; + isPrimary: boolean; +}; const PRESSURE: Record = { 1: "normal", 2: "warning", 4: "critical" }; @@ -76,37 +96,190 @@ function ProcTable({ title, rows, metric }: { title: string; rows: Current["topC ); } -function useSystem(minutes: number, announceWatching: boolean) { +function useMachines() { + const rpc = useRpc(); + const connectionState = useRealtimeConnectionState(); + const [machines, setMachines] = useState([]); + const [primaryHostId, setPrimaryHostId] = useState(null); + const [error, setError] = useState(null); + const requestId = useRef(0); + const hasConnected = useRef(false); + const load = useCallback(async () => { + const id = ++requestId.current; + try { + const result = await rpc.call("machines", null); + if (id !== requestId.current) return; + setMachines(result.machines as Machine[]); + setPrimaryHostId(result.primaryHostId); + setError(null); + } catch (cause) { + if (id !== requestId.current) return; + setError(cause instanceof Error ? cause.message : "Unable to load machines"); + } + }, [rpc]); + useEffect(() => { + void load(); + return () => { requestId.current++; }; + }, [load]); + useEffect(() => { + if (connectionState !== "connected") return; + if (hasConnected.current) void load(); + hasConnected.current = true; + }, [connectionState, load]); + useEffect(() => { + if (!error || connectionState !== "connected") return; + const timer = setTimeout(() => void load(), 5_000); + return () => clearTimeout(timer); + }, [connectionState, error, load]); + useRealtime("system.machines", () => { void load(); }); + return { machines, primaryHostId, error }; +} + +function MachineSelect(props: { + machines: Machine[]; + value: string; + onChange: (hostId: string) => void; +}) { + return ( + + ); +} + +function useSystem(hostId: string | null, minutes: number, announceWatching: boolean) { const rpc = useRpc(); const [cur, setCur] = useState(null); const [hist, setHist] = useState(null); - const load = async () => { - setCur((await rpc.call("current", null)) as Current); - setHist((await rpc.call("history", { minutes })) as Hist); - }; + const [error, setError] = useState(null); + const loadId = useRef(0); + const load = useCallback(async () => { + if (!hostId) return; + const requestId = ++loadId.current; + try { + const [nextCur, nextHist] = await Promise.all([ + rpc.call("current", { hostId }), + rpc.call("history", { hostId, minutes }), + ]); + if (requestId !== loadId.current) return; + setCur(nextCur as Current); + setHist(nextHist as Hist); + setError(null); + } catch (cause) { + if (requestId !== loadId.current) return; + setError(cause instanceof Error ? cause.message : "Unable to load system metrics"); + } + }, [hostId, minutes, rpc]); useEffect(() => { + loadId.current++; + setCur(null); + setHist(null); + setError(null); + if (!hostId) return; void load(); - if (!announceWatching) return; - // Tell the sampler someone is looking, so it samples at the fast cadence. - void rpc.call("watching", null); - const t = setInterval(() => void rpc.call("watching", null), 60_000); - return () => clearInterval(t); - }, []); - useRealtime("system.sample", () => { + const timer = announceWatching + ? setInterval(() => void rpc.call("watching", { hostId }), 60_000) + : null; + if (announceWatching) { + // Tell the sampler someone is looking, so it samples at the fast cadence. + void rpc.call("watching", { hostId }); + } + return () => { + loadId.current++; + if (timer) clearInterval(timer); + }; + }, [announceWatching, hostId, load, rpc]); + useRealtime("system.sample", (event) => { + // The sampler publishes for every connected host; only the machine on + // screen needs a reload. + const payload = event as { hostId?: string } | null; + if (payload?.hostId && payload.hostId !== hostId) return; void load(); }); - return { cur, hist }; + return { cur, hist, error }; } function SystemPanel() { - const { cur, hist } = useSystem(60, true); + const { machines, primaryHostId, error: machineError } = useMachines(); + const [selectedHostId, setSelectedHostId] = useState(null); + useEffect(() => { + if (!machines.length) return; + setSelectedHostId((current) => + current && machines.some((machine) => machine.id === current) + ? current + : primaryHostId ?? machines[0]!.id, + ); + }, [machines, primaryHostId]); + const { cur, hist, error } = useSystem(selectedHostId, 60, true); const s = cur?.sample; - if (!s) return
Sampling… first data arrives shortly.
; const samples = hist?.samples ?? []; - const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel); return (
+
+ {selectedHostId && machines.length > 0 ? ( + + ) : null} +
+ {error ? ( +
+ Could not load this machine: {error} +
+ ) : null} + {machineError && machines.length === 0 ? ( +
+ Could not load machines: {machineError} +
+ ) : null} + {cur?.stale && s ? ( +
+ Showing the last sample from {new Date(s.ts).toLocaleTimeString()}. + {cur.error ? ` ${cur.error}.` : " Live sampling is unavailable."} +
+ ) : null} + {cur?.stale && !s ? ( +
+ {cur.error ?? "This machine is unavailable and has no saved samples yet."} +
+ ) : null} + {!s && !error && !cur?.stale ? ( +
Sampling… first data arrives shortly.
+ ) : null} + {s ? : null} +
+
+ ); +} + +function SystemDetails({ current, samples }: { current: Current; samples: Sample[] }) { + const s = current.sample!; + const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel); + return ( + <>
= 2} /> - +
x.cpuPct)} max={100} /> x.memUsedFrac)} max={1} />
- - + +
-
{cur!.uptime}
- - +
{current.uptime}
+ ); } function HomeTiles() { - const { cur } = useSystem(5, false); + const { primaryHostId } = useMachines(); + const { cur } = useSystem(primaryHostId, 5, false); const s = cur?.sample; if (!s) return null; return ( diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..7911548 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,151 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; + +import { cn } from "../../lib/utils"; +import { usePortalScopeProps } from "../../lib/portal-scope"; +import { CONTROL_HOVER_TRANSITION } from "./motion.js"; +import { Icon } from "../../components/ui/icon.js"; + +const Select = SelectPrimitive.Root; +const SelectGroup = SelectPrimitive.Group; +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1`, + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/package-lock.json b/package-lock.json index f2b0f7a..24d3697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", + "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -33,6 +34,44 @@ "bbPluginSdk": "^0.4.1" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@hugeicons/core-free-icons": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.3.tgz", @@ -48,13 +87,67 @@ "react": ">=16.0.0" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", - "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", @@ -74,7 +167,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -124,11 +216,25 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", @@ -156,7 +262,6 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -172,7 +277,6 @@ "version": "1.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.5", @@ -198,7 +302,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -213,11 +316,42 @@ } } }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-portal": { "version": "1.1.17", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.10", @@ -242,7 +376,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -266,7 +399,6 @@ "version": "2.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.3.3" @@ -286,6 +418,50 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", @@ -308,7 +484,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -324,7 +499,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.7", @@ -345,7 +519,6 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", - "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" @@ -364,7 +537,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -376,6 +548,86 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -410,7 +662,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -577,7 +828,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -618,7 +868,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -805,7 +1054,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -819,7 +1067,6 @@ "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", - "dev": true, "license": "MIT", "dependencies": { "react-remove-scroll-bar": "^2.3.7", @@ -845,7 +1092,6 @@ "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dev": true, "license": "MIT", "dependencies": { "react-style-singleton": "^2.2.2", @@ -868,7 +1114,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dev": true, "license": "MIT", "dependencies": { "get-nonce": "^1.0.0", @@ -927,7 +1172,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT", "peer": true }, @@ -1081,7 +1325,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tunnel-agent": { @@ -1122,7 +1365,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.0.0" @@ -1144,7 +1386,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, "license": "MIT", "dependencies": { "detect-node-es": "^1.1.0", diff --git a/package.json b/package.json index c69e97d..787ce4c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", "@hugeicons/react": "^1.1.6", + "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-slot": "^1.3.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/server.ts b/server.ts index 14b5553..ad989dd 100644 --- a/server.ts +++ b/server.ts @@ -28,6 +28,10 @@ const run = promisify(execFile); const ACTIVE_MS = 15_000; // a thread is running a turn, or the panel is open const IDLE_MS = 60_000; // nobody is watching and nothing is working const RETAIN_MS = 24 * 60 * 60 * 1000; +const PRIMARY_FALLBACK_ID = "__primary__"; +// End-to-end budget: terminal creation, the sampler script (~5 s on macOS, +// most of it the two top samples), and draining the transcript. +const REMOTE_COMMAND_TIMEOUT_MS = 20_000; const sampleShape = z.object({ ts: z.number(), @@ -52,22 +56,37 @@ const procShape = z.object({ command: z.string(), }); +const machineShape = z.object({ + id: z.string(), + name: z.string(), + status: z.enum(["connected", "disconnected"]), + isPrimary: z.boolean(), +}); + +const machineInput = z.object({ hostId: z.string().min(1).optional() }).strict().nullable(); + export const rpcContract = defineRpcContract({ - current: { + machines: { input: z.null(), + output: z.object({ machines: z.array(machineShape), primaryHostId: z.string() }), + }, + current: { + input: machineInput, output: z.object({ sample: sampleShape.nullable(), topCpu: z.array(procShape), topMem: z.array(procShape), uptime: z.string(), + stale: z.boolean(), + error: z.string().nullable(), }), }, history: { - input: z.object({ minutes: z.number().int().min(5).max(1440) }).strict(), + input: z.object({ hostId: z.string().min(1).optional(), minutes: z.number().int().min(5).max(1440) }).strict(), output: z.object({ samples: z.array(sampleShape) }), }, // The panel calls this while mounted so the sampler knows someone is watching. - watching: { input: z.null(), output: z.object({ ok: z.boolean() }) }, + watching: { input: machineInput, output: z.object({ ok: z.boolean() }) }, }); async function pressureLevel(): Promise { @@ -101,6 +120,90 @@ async function topProcesses() { }; } +const REMOTE_SAMPLE_SCRIPT = String.raw` +set -eu +platform=$(uname -s) +if [ "$platform" = "Darwin" ]; then + cpu_count=$(sysctl -n hw.ncpu 2>/dev/null || echo 1) + cpu_pct=$(top -l 2 -n 0 -s 1 2>/dev/null | awk '/CPU usage/ { idle=$7 } END { gsub(/%/, "", idle); if (idle == "") idle=100; printf "%.1f", 100-idle }') + loads=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}') + load1=$(printf '%s\n' "$loads" | awk '{print $1+0}') + load5=$(printf '%s\n' "$loads" | awk '{print $2+0}') + mem_total_kb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1024 )) + mem_used_kb=$(vm_stat 2>/dev/null | awk ' + NR==1 { gsub(/[^0-9]/, "", $0); page=$0+0; next } + /Anonymous pages:/ { anon=$3 } + /Pages active:/ { active=$3 } + /Pages inactive:/ { inactive=$3 } + /Pages purgeable:/ { purgeable=$3 } + /Pages wired down:/ { wired=$4 } + /Pages occupied by compressor:/ { compressed=$5 } + END { + gsub(/\./, "", anon); gsub(/\./, "", active); gsub(/\./, "", inactive); + gsub(/\./, "", purgeable); gsub(/\./, "", wired); gsub(/\./, "", compressed); + if (!anon) anon=active+inactive; + used=(anon-purgeable+wired+compressed)*page/1024; + if (used < 0) used = 0; + printf "%.0f", used + }') + pressure=$(sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null || echo 1) + swap_used_kb=$(sysctl -n vm.swapusage 2>/dev/null | awk '{ for (i=1;i<=NF;i++) if ($i=="used") { v=$(i+2); sub(/M$/, "", v); printf "%.0f", v*1024 } }') +else + cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1) + cpu_pct=$(top -bn2 -d 0.2 2>/dev/null | awk '/^%Cpu/ { idle=$8 } END { if (idle == "") idle=100; printf "%.1f", 100-idle }') + load1=$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo 0) + load5=$(awk '{print $2}' /proc/loadavg 2>/dev/null || echo 0) + mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) + mem_available_kb=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) + mem_used_kb=$((mem_total_kb-mem_available_kb)) + pressure=1 + swap_total_kb=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) + swap_free_kb=$(awk '/^SwapFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0) + swap_used_kb=$((swap_total_kb-swap_free_kb)) +fi +disk_path=/ +if [ "$platform" = "Darwin" ] && [ -d /System/Volumes/Data ]; then + disk_path=/System/Volumes/Data +fi +disk=$(df -k "$disk_path" 2>/dev/null | tail -n 1) +disk_total_kb=$(printf '%s\n' "$disk" | awk '{print $2+0}') +disk_used_kb=$(printf '%s\n' "$disk" | awk '{print $3+0}') +echo __BB_SYSTEM_BEGIN__ +echo "cpu_pct=$cpu_pct" +echo "cpu_count=$cpu_count" +echo "load1=$load1" +echo "load5=$load5" +echo "mem_total_kb=$mem_total_kb" +echo "mem_used_kb=$mem_used_kb" +echo "pressure=$pressure" +echo "swap_used_kb=$swap_used_kb" +echo "disk_total_kb=$disk_total_kb" +echo "disk_used_kb=$disk_used_kb" +printf 'uptime=%s\n' "$(uptime 2>/dev/null || true)" +ps -axo pid=,pcpu=,rss=,comm= 2>/dev/null | sort -k2,2nr | head -n 8 | while read -r pid cpu rss command; do + printf 'cpu_proc=%s|%s|%s|%s\n' "$pid" "$cpu" "$rss" "$command" +done +ps -axo pid=,pcpu=,rss=,comm= 2>/dev/null | sort -k3,3nr | head -n 8 | while read -r pid cpu rss command; do + printf 'mem_proc=%s|%s|%s|%s\n' "$pid" "$cpu" "$rss" "$command" +done +echo __BB_SYSTEM_END__ +`; + +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function decodeTerminalOutput(chunks: { dataBase64: string }[]) { + return chunks.map((chunk) => Buffer.from(chunk.dataBase64, "base64").toString("utf8")).join(""); +} + +type CurrentData = { + sample: Sample; + topCpu: z.infer[]; + topMem: z.infer[]; + uptime: string; +}; + export default async function plugin(bb: BbPluginApi) { const db = bb.storage.database(); // Append-only migrations. v1 shipped mem_pressure (a misnomer for used/total) @@ -115,14 +218,56 @@ export default async function plugin(bb: BbPluginApi) { )`, `ALTER TABLE samples ADD COLUMN cpu_pct REAL`, `ALTER TABLE samples ADD COLUMN pressure_level INTEGER`, + `CREATE TABLE IF NOT EXISTS samples_by_host ( + host_id TEXT NOT NULL, + ts INTEGER NOT NULL, + load1 REAL, load5 REAL, cpu_count INTEGER, + mem_total_mb INTEGER, mem_used_mb INTEGER, mem_pressure REAL, + swap_used_mb INTEGER, disk_total_gb INTEGER, disk_used_gb INTEGER, + cpu_pct REAL, pressure_level INTEGER, + PRIMARY KEY (host_id, ts) + )`, ]); + const config = await bb.sdk.system.config(); + const primaryHostId = config.primaryHostId ?? PRIMARY_FALLBACK_ID; + + // Import the legacy single-machine table exactly once. If the first load had + // no resolved primary ID, merge that fallback bucket into the real primary + // later instead of copying the same history under every changed host ID. + const legacyImportKey = "legacy-history-import-host"; + const importedHostId = await bb.storage.kv.get(legacyImportKey); + const copyLegacy = db.prepare( + `INSERT OR IGNORE INTO samples_by_host + (host_id, ts, load1, load5, cpu_count, mem_total_mb, mem_used_mb, + mem_pressure, swap_used_mb, disk_total_gb, disk_used_gb, cpu_pct, pressure_level) + SELECT ?, ts, load1, load5, cpu_count, mem_total_mb, mem_used_mb, + mem_pressure, swap_used_mb, disk_total_gb, disk_used_gb, cpu_pct, pressure_level + FROM samples`, + ); + if (importedHostId === undefined) { + copyLegacy.run(primaryHostId); + await bb.storage.kv.set(legacyImportKey, primaryHostId); + } else if (importedHostId === PRIMARY_FALLBACK_ID && primaryHostId !== PRIMARY_FALLBACK_ID) { + const mergeFallback = db.transaction(() => { + db.prepare( + `INSERT OR IGNORE INTO samples_by_host SELECT ?, ts, load1, load5, cpu_count, + mem_total_mb, mem_used_mb, mem_pressure, swap_used_mb, disk_total_gb, + disk_used_gb, cpu_pct, pressure_level + FROM samples_by_host WHERE host_id = ?`, + ).run(primaryHostId, PRIMARY_FALLBACK_ID); + db.prepare(`DELETE FROM samples_by_host WHERE host_id = ?`).run(PRIMARY_FALLBACK_ID); + }); + mergeFallback(); + await bb.storage.kv.set(legacyImportKey, primaryHostId); + } + // Hardware constants: read once at load, not re-sampled every tick. const [cpuInfo, memInfo] = await Promise.all([si.cpu(), si.mem()]); const cpuCount = cpuInfo.physicalCores * 0 + (cpuInfo.cores || 1); const memTotalMb = Math.round(memInfo.total / 1048576); - async function takeSample(): Promise { + async function takeLocalSample(): Promise { const [load, mem, fs, pressure] = await Promise.all([ si.currentLoad(), si.mem(), @@ -149,18 +294,17 @@ export default async function plugin(bb: BbPluginApi) { }; } - const insert = (s: Sample) => { + const insert = (hostId: string, s: Sample) => { db.prepare( - `INSERT OR REPLACE INTO samples - (ts, load1, load5, cpu_count, mem_total_mb, mem_used_mb, mem_pressure, + `INSERT OR REPLACE INTO samples_by_host + (host_id, ts, load1, load5, cpu_count, mem_total_mb, mem_used_mb, mem_pressure, swap_used_mb, disk_total_gb, disk_used_gb, cpu_pct, pressure_level) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, ).run( - s.ts, s.load1, s.load5, s.cpuCount, s.memTotalMb, s.memUsedMb, s.memUsedFrac, + hostId, s.ts, s.load1, s.load5, s.cpuCount, s.memTotalMb, s.memUsedMb, s.memUsedFrac, s.swapUsedMb, s.diskTotalGb, s.diskUsedGb, s.cpuPct, s.pressureLevel, ); - // Index range scan, not a 5760-row sort on every tick. - db.prepare(`DELETE FROM samples WHERE ts < ?`).run(Date.now() - RETAIN_MS); + db.prepare(`DELETE FROM samples_by_host WHERE ts < ?`).run(Date.now() - RETAIN_MS); }; const rowToSample = (r: Record): Sample => { @@ -186,31 +330,254 @@ export default async function plugin(bb: BbPluginApi) { }; }; - const latest = (): Sample | null => { - const r = db.prepare(`SELECT * FROM samples ORDER BY ts DESC LIMIT 1`).get() as + const latest = (hostId: string): Sample | null => { + const r = db.prepare(`SELECT * FROM samples_by_host WHERE host_id = ? ORDER BY ts DESC LIMIT 1`).get(hostId) as | Record | undefined; return r ? rowToSample(r) : null; }; + const remoteCurrent = new Map>(); + const sampling = new Map>(); + const lastAttemptAt = new Map(); + const backgroundSamples = new Set>(); + + const parseProcess = (value: string) => { + const [pid, cpu, rss, ...command] = value.split("|"); + return { + pid: Number(pid), + cpu: Number(cpu), + memMb: Math.round(Number(rss) / 1024), + command: command.join("|").split("/").pop()!.slice(0, 48), + }; + }; + + function abortableDelay(ms: number, signal?: AbortSignal) { + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal?.addEventListener("abort", finish, { once: true }); + }); + } + + // Combine the caller's abort signal with a deadline so every terminal call + // is itself time-bounded; without AbortSignal.timeout/any (older runtimes) + // this degrades to the caller's signal alone, still bounded by bb's own + // terminal-operation timeouts. + function signalWithTimeout(signal: AbortSignal | undefined, remainingMs: number): AbortSignal { + if (typeof AbortSignal.timeout !== "function") return signal ?? new AbortController().signal; + const timeout = AbortSignal.timeout(Math.max(1, remainingMs)); + return signal && typeof AbortSignal.any === "function" + ? AbortSignal.any([signal, timeout]) + : signal ?? timeout; + } + + async function takeRemoteSample(hostId: string, signal?: AbortSignal): Promise { + // Run the sampler as a one-shot command instead of pasting it into a + // reused interactive shell: on macOS a multi-line terminals.input write + // drops bytes, so the shell never sees the full script. Each sample gets a + // fresh terminal, so failed hosts cannot churn a long-lived remote shell. + // base64 -D (macOS) vs -d (GNU) is handled by trying both. The trailing + // `cat` holds the session open (its stdin is the PTY, which never receives + // input) until the drain loop has captured the full transcript, so output + // cannot become unavailable mid-read; the finally block reaps the + // terminal — and with it any hung command — afterwards. + const payload = Buffer.from(REMOTE_SAMPLE_SCRIPT, "utf8").toString("base64"); + const command = [ + "printf %s", shellQuote(payload), + "| { base64 -D 2>/dev/null || base64 -d; }", + "| /bin/sh; cat >/dev/null", + ].join(" "); + const deadline = Date.now() + REMOTE_COMMAND_TIMEOUT_MS; + const terminal = await bb.sdk.terminals.create({ + cols: 200, + rows: 50, + scope: { kind: "host_path", hostId, cwd: null }, + start: { mode: "command", command: "/bin/sh -c " + shellQuote(command) }, + title: "System metrics", + }); + let text = ""; + let nextSeq = 0; + try { + let state = terminal; + while (state.status === "starting") { + if (signal?.aborted) throw new Error("sampling cancelled"); + if (Date.now() >= deadline) throw new Error("metric terminal timed out"); + await abortableDelay(Math.min(150, deadline - Date.now()), signal); + state = await (async () => { + try { + return await bb.sdk.terminals.get({ + terminalId: terminal.id, + signal: signalWithTimeout(signal, deadline - Date.now()), + }); + } catch (error) { + if (signal?.aborted) throw new Error("sampling cancelled"); + if (Date.now() >= deadline) throw new Error("metric terminal timed out"); + throw error; + } + })(); + } + // The blocking cat keeps the session running until we close it, so a + // session that is no longer running never has a complete transcript. + if (state.status !== "running") throw new Error("metric terminal did not stay running"); + while (!text.includes("__BB_SYSTEM_END__")) { + if (signal?.aborted) throw new Error("sampling cancelled"); + if (Date.now() >= deadline) throw new Error("metric command timed out"); + const output = await (async () => { + try { + return await bb.sdk.terminals.output({ + terminalId: terminal.id, + sinceSeq: nextSeq, + limitChunks: 500, + signal: signalWithTimeout(signal, deadline - Date.now()), + }); + } catch (error) { + if (signal?.aborted) throw new Error("sampling cancelled"); + if (Date.now() >= deadline) throw new Error("metric command timed out"); + throw error; + } + })(); + text += decodeTerminalOutput(output.chunks); + nextSeq = output.nextSeq; + if (!text.includes("__BB_SYSTEM_END__")) await abortableDelay(Math.min(150, deadline - Date.now()), signal); + } + const match = text.match(/__BB_SYSTEM_BEGIN__\r?\n([\s\S]*?)__BB_SYSTEM_END__/); + if (!match) throw new Error("metric command returned no data"); + const values = new Map(); + const topCpu: z.infer[] = []; + const topMem: z.infer[] = []; + for (const rawLine of match[1]!.split(/\r?\n/)) { + const line = rawLine.trim(); + const at = line.indexOf("="); + if (at < 1) continue; + const key = line.slice(0, at); + const value = line.slice(at + 1); + if (key === "cpu_proc") topCpu.push(parseProcess(value)); + else if (key === "mem_proc") topMem.push(parseProcess(value)); + else values.set(key, value); + } + const num = (key: string) => Number(values.get(key)) || 0; + const memTotalMb = Math.round(num("mem_total_kb") / 1024); + const memUsedMb = Math.round(num("mem_used_kb") / 1024); + const sample: Sample = { + ts: Date.now(), + cpuPct: Math.min(100, Math.max(0, num("cpu_pct"))), + load1: num("load1"), + load5: num("load5"), + cpuCount: Math.max(1, num("cpu_count")), + memTotalMb, + memUsedMb, + memUsedFrac: memTotalMb ? memUsedMb / memTotalMb : 0, + pressureLevel: num("pressure") || 1, + swapUsedMb: Math.round(num("swap_used_kb") / 1024), + diskTotalGb: Math.round(num("disk_total_kb") / 1048576), + diskUsedGb: Math.round(num("disk_used_kb") / 1048576), + }; + return { sample, topCpu, topMem, uptime: values.get("uptime") ?? "" }; + } finally { + // Always reap the one-shot terminal — success, failure, or abort. On + // success the blocking cat is still holding the session open; on + // timeout or shutdown this is what kills the hung command. The abort + // guard is deliberately absent: a service reload is exactly when a + // stranded remote shell must not be left behind. + void bb.sdk.terminals.close({ terminalId: terminal.id, mode: "force" }).catch(() => undefined); + } + } + + bb.onDispose(async () => { + await Promise.race([ + Promise.allSettled([...backgroundSamples]), + new Promise((resolve) => setTimeout(resolve, 2_500)), + ]); + }); + + async function sampleHost(hostId: string, signal?: AbortSignal): Promise { + const running = sampling.get(hostId); + if (running) return running; + lastAttemptAt.set(hostId, Date.now()); + const promise = (async () => { + let current: CurrentData; + if (hostId === primaryHostId) { + const [sample, processes, uptimeResult] = await Promise.all([ + takeLocalSample(), + topProcesses(), + run("/usr/bin/uptime", []), + ]); + current = { sample, ...processes, uptime: uptimeResult.stdout.trim() }; + } else { + current = await takeRemoteSample(hostId, signal); + } + insert(hostId, current.sample); + remoteCurrent.set(hostId, { + topCpu: current.topCpu, + topMem: current.topMem, + uptime: current.uptime, + }); + bb.realtime.publish("system.sample", { hostId, at: current.sample.ts }); + return current; + })().finally(() => sampling.delete(hostId)); + sampling.set(hostId, promise); + return promise; + } + // Adaptive cadence. A fixed short interval that spawns work every tick fights // macOS timer coalescing and keeps cores out of deep idle for nothing; App Nap // stretches it silently when bb is backgrounded anyway. Sample fast only when // the data is actually wanted: a thread is running, or the panel is mounted. let activeThreads = 0; - let watchingUntil = 0; + const watchingUntil = new Map(); bb.events.on("thread.active", () => { activeThreads++; }); bb.events.on("thread.idle", () => { activeThreads = Math.max(0, activeThreads - 1); }); bb.events.on("thread.failed", () => { activeThreads = Math.max(0, activeThreads - 1); }); const interval = () => - activeThreads > 0 || Date.now() < watchingUntil ? ACTIVE_MS : IDLE_MS; + activeThreads > 0 || [...watchingUntil.values()].some((until) => Date.now() < until) + ? ACTIVE_MS + : IDLE_MS; + + const unsubscribeHosts = bb.sdk.subscribe({ + event: "host:changed", + callback: () => bb.realtime.publish("system.machines", { at: Date.now() }), + }); + bb.onDispose(unsubscribeHosts); bb.background.service("sampler", { async start(signal) { while (!signal.aborted) { try { - insert(await takeSample()); - bb.realtime.publish("system.sample", { at: Date.now() }); + const now = Date.now(); + const hosts = await bb.sdk.hosts.list({ signal }); + const hostIds = new Set([ + primaryHostId, + ...hosts.filter((host) => host.status === "connected").map((host) => host.id), + ]); + for (const [hostId, until] of watchingUntil) { + if (until <= now) watchingUntil.delete(hostId); + } + let availableSlots = Math.max(0, 4 - backgroundSamples.size); + for (const hostId of hostIds) { + if (availableSlots === 0) break; + const watched = (watchingUntil.get(hostId) ?? 0) > now; + const cadence = watched || (hostId === primaryHostId && activeThreads > 0) + ? ACTIVE_MS + : IDLE_MS; + const mostRecent = lastAttemptAt.get(hostId) ?? latest(hostId)?.ts ?? 0; + if (now - mostRecent < cadence || sampling.has(hostId)) continue; + availableSlots--; + let task!: Promise; + task = sampleHost(hostId, signal) + .then(() => undefined) + .catch((error) => { + if (!signal.aborted) { + bb.log.warn(`sample failed for ${hostId}: ${error instanceof Error ? error.message : error}`); + } + }) + .finally(() => backgroundSamples.delete(task)); + backgroundSamples.add(task); + } } catch (e) { bb.log.warn(`sample failed: ${e instanceof Error ? e.message : e}`); } @@ -223,19 +590,92 @@ export default async function plugin(bb: BbPluginApi) { }); bb.rpc.register(rpcContract, { - async current() { - const { topCpu, topMem } = await topProcesses(); - const { stdout } = await run("/usr/bin/uptime", []); - return { sample: latest(), topCpu, topMem, uptime: stdout.trim() }; + async machines() { + const hosts = await bb.sdk.hosts.list(); + const machines = hosts.map((host) => ({ + id: host.id, + name: host.name, + status: host.status, + isPrimary: host.id === primaryHostId, + })); + if (!machines.some((machine) => machine.id === primaryHostId)) { + machines.unshift({ + id: primaryHostId, + name: "This machine", + status: "connected", + isPrimary: true, + }); + } + machines.sort( + (a, b) => + Number(b.isPrimary) - Number(a.isPrimary) || + Number(b.status === "connected") - Number(a.status === "connected") || + a.name.localeCompare(b.name), + ); + return { machines, primaryHostId }; + }, + async current(input) { + const hostId = input?.hostId ?? primaryHostId; + let sample = latest(hostId); + let stale = false; + let sampleError: string | null = null; + let canSample = true; + if (hostId !== primaryHostId) { + try { + const host = await bb.sdk.hosts.get({ hostId }); + if (host.status !== "connected") { + stale = true; + canSample = false; + sampleError = `${host.name} is offline`; + } + } catch (error) { + stale = true; + canSample = false; + sampleError = error instanceof Error ? error.message : "Machine is unavailable"; + } + } + if (canSample && ( + !sample || + Date.now() - sample.ts > ACTIVE_MS * 2 || + (hostId !== primaryHostId && !remoteCurrent.has(hostId)) + )) { + try { + return { ...(await sampleHost(hostId)), stale: false, error: null }; + } catch (error) { + if (!sample) throw error; + stale = true; + sampleError = error instanceof Error ? error.message : "Machine sampling failed"; + } + } + const details = remoteCurrent.get(hostId); + if (hostId === primaryHostId) { + const [processes, uptimeResult] = await Promise.all([topProcesses(), run("/usr/bin/uptime", [])]); + return { + sample, + ...processes, + uptime: uptimeResult.stdout.trim(), + stale, + error: sampleError, + }; + } + return { + sample, + topCpu: details?.topCpu ?? [], + topMem: details?.topMem ?? [], + uptime: details?.uptime ?? "", + stale, + error: sampleError, + }; }, - history({ minutes }) { + history({ hostId = primaryHostId, minutes }) { const rows = db - .prepare(`SELECT * FROM samples WHERE ts >= ? ORDER BY ts`) - .all(Date.now() - minutes * 60_000) as Record[]; + .prepare(`SELECT * FROM samples_by_host WHERE host_id = ? AND ts >= ? ORDER BY ts`) + .all(hostId, Date.now() - minutes * 60_000) as Record[]; return { samples: rows.map(rowToSample) }; }, - watching() { - watchingUntil = Date.now() + 90_000; + watching(input) { + const hostId = input?.hostId ?? primaryHostId; + watchingUntil.set(hostId, Date.now() + 90_000); return { ok: true }; }, }); @@ -269,8 +709,8 @@ export default async function plugin(bb: BbPluginApi) { if (cmd === "history") { const minutes = Math.min(1440, parseInt(argv[1] ?? "60", 10) || 60); const rows = db - .prepare(`SELECT * FROM samples WHERE ts >= ? ORDER BY ts`) - .all(Date.now() - minutes * 60_000) as Record[]; + .prepare(`SELECT * FROM samples_by_host WHERE host_id = ? AND ts >= ? ORDER BY ts`) + .all(primaryHostId, Date.now() - minutes * 60_000) as Record[]; if (!rows.length) return { exitCode: 0, stdout: "no samples yet" }; const step = Math.max(1, Math.floor(rows.length / 24)); const lines: string[] = []; @@ -282,7 +722,7 @@ export default async function plugin(bb: BbPluginApi) { } return { exitCode: 0, stdout: lines.join("\n") }; } - const s = latest() ?? (await takeSample()); + const s = latest(primaryHostId) ?? (await takeLocalSample()); const { stdout: up } = await run("/usr/bin/uptime", []); return { exitCode: 0,