Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -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.
Expand Down
223 changes: 198 additions & 25 deletions app.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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<number, string> = { 1: "normal", 2: "warning", 4: "critical" };

Expand Down Expand Up @@ -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<typeof rpcContract>();
const connectionState = useRealtimeConnectionState();
const [machines, setMachines] = useState<Machine[]>([]);
const [primaryHostId, setPrimaryHostId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Select value={props.value} onValueChange={props.onChange}>
<SelectTrigger
aria-label="Machine"
className="h-8 border-border/70 bg-muted/20 px-2.5 py-0 text-xs font-medium shadow-none hover:bg-muted/40 focus:ring-1 data-[state=open]:bg-muted/40 [&>svg]:size-3.5 [&>svg]:opacity-60"
style={{ width: 180 }}
>
<SelectValue />
</SelectTrigger>
<SelectContent
align="end"
sideOffset={4}
className="[&_[role=option]>span:last-child]:truncate"
style={{ width: "var(--radix-select-trigger-width)", minWidth: "var(--radix-select-trigger-width)" }}
>
{props.machines.map((machine) => (
<SelectItem
key={machine.id}
value={machine.id}
disabled={machine.status === "disconnected"}
className="text-xs"
>
{machine.name}{machine.isPrimary ? " (primary)" : ""}
{machine.status === "disconnected" ? " — offline" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

function useSystem(hostId: string | null, minutes: number, announceWatching: boolean) {
const rpc = useRpc<typeof rpcContract>();
const [cur, setCur] = useState<Current | null>(null);
const [hist, setHist] = useState<Hist | null>(null);
const load = async () => {
setCur((await rpc.call("current", null)) as Current);
setHist((await rpc.call("history", { minutes })) as Hist);
};
const [error, setError] = useState<string | null>(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<string | null>(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 <div className="p-5 text-muted-foreground text-sm">Sampling… first data arrives shortly.</div>;
const samples = hist?.samples ?? [];
const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel);
return (
<div className="p-4 md:p-5 overflow-y-auto h-full">
<div className="mx-auto w-full max-w-3xl space-y-4">
<div className="flex justify-end">
{selectedHostId && machines.length > 0 ? (
<MachineSelect machines={machines} value={selectedHostId} onChange={setSelectedHostId} />
) : null}
</div>
{error ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
Could not load this machine: {error}
</div>
) : null}
{machineError && machines.length === 0 ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
Could not load machines: {machineError}
</div>
) : null}
{cur?.stale && s ? (
<div className="rounded-lg border border-border bg-muted/60 px-4 py-3 text-sm text-foreground">
Showing the last sample from {new Date(s.ts).toLocaleTimeString()}.
{cur.error ? ` ${cur.error}.` : " Live sampling is unavailable."}
</div>
) : null}
{cur?.stale && !s ? (
<div className="rounded-lg border border-border bg-muted/60 px-4 py-3 text-sm text-foreground">
{cur.error ?? "This machine is unavailable and has no saved samples yet."}
</div>
) : null}
{!s && !error && !cur?.stale ? (
<div className="py-8 text-center text-muted-foreground text-sm">Sampling… first data arrives shortly.</div>
) : null}
{s ? <SystemDetails current={cur!} samples={samples} /> : null}
</div>
</div>
);
}

function SystemDetails({ current, samples }: { current: Current; samples: Sample[] }) {
const s = current.sample!;
const pressure = PRESSURE[s.pressureLevel] ?? String(s.pressureLevel);
return (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Tile label="CPU" value={`${Math.round(s.cpuPct)}%`} sub={`${s.cpuCount} cores · load ${s.load1.toFixed(2)}`} frac={s.cpuPct / 100} />
<Tile
Expand All @@ -116,24 +289,24 @@ function SystemPanel() {
frac={s.memUsedFrac}
hot={s.pressureLevel >= 2}
/>
<Tile label="Disk" value={`${s.diskUsedGb} GB`} sub={`of ${s.diskTotalGb} GB (data volume)`} frac={s.diskUsedGb / (s.diskTotalGb || 1)} />
<Tile label="Disk" value={`${s.diskUsedGb} GB`} sub={`of ${s.diskTotalGb} GB`} frac={s.diskUsedGb / (s.diskTotalGb || 1)} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Spark title="CPU % — last hour" points={samples.map((x) => x.cpuPct)} max={100} />
<Spark title="Memory used — last hour" points={samples.map((x) => x.memUsedFrac)} max={1} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ProcTable title="Top CPU" rows={cur!.topCpu} metric="cpu" />
<ProcTable title="Top memory" rows={cur!.topMem} metric="mem" />
<ProcTable title="Top CPU" rows={current.topCpu} metric="cpu" />
<ProcTable title="Top memory" rows={current.topMem} metric="mem" />
</div>
<div className="text-xs text-muted-foreground">{cur!.uptime}</div>
</div>
</div>
<div className="text-xs text-muted-foreground">{current.uptime}</div>
</>
);
}

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 (
Expand Down
Loading
Loading