diff --git a/README.md b/README.md index 48304f7..e7a9a41 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ bb plugin install git:https://github.com/MGrin/bb-plugin-system.git@main 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. +**Homepage tiles** — a compact CPU / memory / disk row for the primary bb machine. +They are shown by default; toggle **Show system stats on Home** in the top-right of +the System panel to show or hide them. The saved choice applies instantly and is +remembered. **`bb system`** — the same data for agents, as text: diff --git a/app.tsx b/app.tsx index 48d122a..a755d38 100644 --- a/app.tsx +++ b/app.tsx @@ -1,5 +1,5 @@ // bb-plugin-system frontend — System panel + homepage tiles. -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { definePluginApp, useRealtime, @@ -13,6 +13,7 @@ import { SelectTrigger, SelectValue, } from "./components/ui/select"; +import { Button } from "./components/ui/button"; import type { rpcContract } from "./server"; type Sample = { @@ -37,6 +38,23 @@ type Machine = { }; const PRESSURE: Record = { 1: "normal", 2: "warning", 4: "critical" }; +const SELECTED_MACHINE_KEY = "bb-plugin-system:selected-machine"; + +function useHomepageSectionVisibility( + rootRef: { current: HTMLDivElement | null }, + visible: boolean, +) { + useLayoutEffect(() => { + const section = rootRef.current?.closest("section"); + if (!section) return; + + section.hidden = !visible; + + return () => { + section.hidden = false; + }; + }, [rootRef, visible]); +} function Meter({ frac, tone }: { frac: number; tone: "ok" | "warn" | "hot" }) { const cls = tone === "hot" ? "bg-destructive" : tone === "warn" ? "bg-primary/70" : "bg-primary"; @@ -171,6 +189,43 @@ function MachineSelect(props: { ); } +function useSelectedMachine(machines: Machine[], primaryHostId: string | null) { + const [selectedHostId, setSelectedHostId] = useState(() => { + try { + return window.localStorage.getItem(SELECTED_MACHINE_KEY); + } catch { + return null; + } + }); + useEffect(() => { + if (!machines.length) return; + setSelectedHostId((current) => + current && machines.some((machine) => machine.id === current) + ? current + : primaryHostId ?? machines[0]!.id, + ); + }, [machines, primaryHostId]); + useEffect(() => { + if (!selectedHostId) return; + try { + window.localStorage.setItem(SELECTED_MACHINE_KEY, selectedHostId); + } catch { + // Client storage may be unavailable in hardened browser contexts. The + // in-memory selection still works for the current mount. + } + }, [selectedHostId]); + useEffect(() => { + const syncSelection = (event: StorageEvent) => { + if (event.key === SELECTED_MACHINE_KEY && event.newValue) { + setSelectedHostId(event.newValue); + } + }; + window.addEventListener("storage", syncSelection); + return () => window.removeEventListener("storage", syncSelection); + }, []); + return [selectedHostId, setSelectedHostId] as const; +} + function useSystem(hostId: string | null, minutes: number, announceWatching: boolean) { const rpc = useRpc(); const [cur, setCur] = useState(null); @@ -178,12 +233,11 @@ function useSystem(hostId: string | null, minutes: number, announceWatching: boo 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 }), + rpc.call("current", hostId ? { hostId } : null), + rpc.call("history", hostId ? { hostId, minutes } : { minutes }), ]); if (requestId !== loadId.current) return; setCur(nextCur as Current); @@ -199,14 +253,13 @@ function useSystem(hostId: string | null, minutes: number, announceWatching: boo setCur(null); setHist(null); setError(null); - if (!hostId) return; void load(); const timer = announceWatching - ? setInterval(() => void rpc.call("watching", { hostId }), 60_000) + ? setInterval(() => void rpc.call("watching", hostId ? { hostId } : null), 60_000) : null; if (announceWatching) { // Tell the sampler someone is looking, so it samples at the fast cadence. - void rpc.call("watching", { hostId }); + void rpc.call("watching", hostId ? { hostId } : null); } return () => { loadId.current++; @@ -217,30 +270,133 @@ function useSystem(hostId: string | null, minutes: number, announceWatching: boo // 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; + if (hostId && payload?.hostId && payload.hostId !== hostId) return; void load(); }); return { cur, hist, error }; } -function SystemPanel() { - const { machines, primaryHostId, error: machineError } = useMachines(); - const [selectedHostId, setSelectedHostId] = useState(null); +// The homepage-tiles visibility toggle lives in the System panel, so both +// slots (panel and homepage section) share this hook and stay in sync via +// realtime — no host settings page, no save button, instant on click. +function useHomeVisibility() { + const rpc = useRpc(); + const connectionState = useRealtimeConnectionState(); + const [showOnHomepage, setShowOnHomepage] = useState(null); + const [retryPending, setRetryPending] = useState(false); + const [error, setError] = useState(null); + const hasConnected = useRef(false); + const load = useCallback(async (isRetry = false) => { + setError(null); + try { + const result = await rpc.call("homeVisibility", null); + setShowOnHomepage(result.showOnHomepage); + setRetryPending(false); + } catch (cause) { + if (isRetry) { + setRetryPending(false); + setError(cause instanceof Error ? cause.message : "Unable to load Home visibility"); + } else { + setRetryPending(true); + } + } + }, [rpc]); useEffect(() => { - if (!machines.length) return; - setSelectedHostId((current) => - current && machines.some((machine) => machine.id === current) - ? current - : primaryHostId ?? machines[0]!.id, + void load(); + }, [load]); + useEffect(() => { + if (connectionState !== "connected") return; + if (hasConnected.current) void load(); + hasConnected.current = true; + }, [connectionState, load]); + useEffect(() => { + if (!retryPending || connectionState !== "connected") return; + const timer = setTimeout(() => { + setRetryPending(false); + void load(true); + }, 5_000); + return () => clearTimeout(timer); + }, [connectionState, load, retryPending]); + useRealtime("system.home-visibility", (event) => { + const payload = event as { showOnHomepage?: boolean } | null; + if (typeof payload?.showOnHomepage === "boolean") { + setShowOnHomepage(payload.showOnHomepage); + setRetryPending(false); + setError(null); + } + }); + const setVisible = useCallback( + async (next: boolean) => { + setShowOnHomepage(next); + try { + await rpc.call("setHomeVisibility", { showOnHomepage: next }); + } catch { + setShowOnHomepage(null); + void load(false); + } + }, + [load, rpc], + ); + return { + showOnHomepage, + setShowOnHomepage: setVisible, + error, + retry: () => void load(false), + }; +} + +function ShowOnHomeToggle() { + const { showOnHomepage, setShowOnHomepage, error, retry } = useHomeVisibility(); + if (error) { + return ( +
+ Could not load Home visibility. + +
); - }, [machines, primaryHostId]); + } + if (showOnHomepage === null) { + return Loading Home visibility…; + } + const on = showOnHomepage === true; + return ( + + ); +} + +function SystemPanel() { + const { machines, primaryHostId, error: machineError } = useMachines(); + const [selectedHostId, setSelectedHostId] = useSelectedMachine(machines, primaryHostId); const { cur, hist, error } = useSystem(selectedHostId, 60, true); const s = cur?.sample; const samples = hist?.samples ?? []; return (
-
+
+ {selectedHostId && machines.length > 0 ? ( ) : null} @@ -304,27 +460,51 @@ function SystemDetails({ current, samples }: { current: Current; samples: Sample ); } -function HomeTiles() { - const { primaryHostId } = useMachines(); - const { cur } = useSystem(primaryHostId, 5, false); +function VisibleHomeTiles({ hostId }: { hostId: string | null }) { + const { cur, error } = useSystem(hostId, 5, false); const s = cur?.sample; - if (!s) return null; return ( -
- - = 2} - /> - +
+ {!s && error ? ( +
+ Could not load system stats: {error} +
+ ) : !s ? ( +
+ Sampling… first data arrives shortly. +
+ ) : ( +
+ + = 2} + /> + +
+ )}
); } +function HomeTiles() { + const { showOnHomepage } = useHomeVisibility(); + const rootRef = useRef(null); + // Unknown stays hidden until the persisted preference loads. Treating it as + // visible caused the disabled section to flash its loading state on Home. + const isVisible = showOnHomepage === true; + + // homepageSection has no conditional-registration API, so hide the + // host-owned section together with the plugin content. + useHomepageSectionVisibility(rootRef, isVisible); + + return
{isVisible ? : null}
; +} + export default definePluginApp((app) => { app.slots.navPanel({ id: "system", title: "System", icon: "Activity", path: "system", component: SystemPanel }); - app.slots.homepageSection({ id: "system-tiles", title: "System", component: HomeTiles }); + app.slots.homepageSection({ id: "system-tiles", title: "System Stats", component: HomeTiles }); }); diff --git a/server.ts b/server.ts index ad989dd..24ae150 100644 --- a/server.ts +++ b/server.ts @@ -87,6 +87,16 @@ export const rpcContract = defineRpcContract({ }, // The panel calls this while mounted so the sampler knows someone is watching. watching: { input: machineInput, output: z.object({ ok: z.boolean() }) }, + // Homepage-tiles visibility. Stored by the plugin itself (not the host's + // settings page) so the panel's toggle takes effect the moment it is clicked. + homeVisibility: { + input: z.null(), + output: z.object({ showOnHomepage: z.boolean() }), + }, + setHomeVisibility: { + input: z.object({ showOnHomepage: z.boolean() }).strict(), + output: z.object({ ok: z.boolean() }), + }, }); async function pressureLevel(): Promise { @@ -678,6 +688,15 @@ export default async function plugin(bb: BbPluginApi) { watchingUntil.set(hostId, Date.now() + 90_000); return { ok: true }; }, + async homeVisibility() { + const raw = await bb.storage.kv.get("showOnHomepage"); + return { showOnHomepage: raw !== "0" }; + }, + async setHomeVisibility(input) { + await bb.storage.kv.set("showOnHomepage", input.showOnHomepage ? "1" : "0"); + bb.realtime.publish("system.home-visibility", { showOnHomepage: input.showOnHomepage }); + return { ok: true }; + }, }); const pct = (n: number) => `${Math.round(n * 100)}%`;