From f27bfb8e624f79778c6e0c42803317cfd3e3a79b Mon Sep 17 00:00:00 2001 From: Nicolas Charpentier Date: Mon, 17 Aug 2026 18:49:33 -0400 Subject: [PATCH 1/2] Add Disk Usage plugin --- .bb/plugins.json | 1 + README.md | 1 + plugins/disk-usage/.gitignore | 2 + plugins/disk-usage/README.md | 31 +++ plugins/disk-usage/app.tsx | 260 +++++++++++++++++++++++ plugins/disk-usage/assets/hard-drive.svg | 6 + plugins/disk-usage/package.json | 63 ++++++ plugins/disk-usage/scan.ts | 185 ++++++++++++++++ plugins/disk-usage/server.test.ts | 89 ++++++++ plugins/disk-usage/server.ts | 123 +++++++++++ plugins/disk-usage/tsconfig.json | 14 ++ plugins/disk-usage/vitest.config.ts | 10 + pnpm-lock.yaml | 34 +++ 13 files changed, 819 insertions(+) create mode 100644 plugins/disk-usage/.gitignore create mode 100644 plugins/disk-usage/README.md create mode 100644 plugins/disk-usage/app.tsx create mode 100644 plugins/disk-usage/assets/hard-drive.svg create mode 100644 plugins/disk-usage/package.json create mode 100644 plugins/disk-usage/scan.ts create mode 100644 plugins/disk-usage/server.test.ts create mode 100644 plugins/disk-usage/server.ts create mode 100644 plugins/disk-usage/tsconfig.json create mode 100644 plugins/disk-usage/vitest.config.ts diff --git a/.bb/plugins.json b/.bb/plugins.json index 5bb2b82..e5b8cd6 100644 --- a/.bb/plugins.json +++ b/.bb/plugins.json @@ -4,6 +4,7 @@ "name": "charpeni-plugins", "plugins": [ { "name": "dependabot", "source": "./plugins/dependabot" }, + { "name": "disk-usage", "source": "./plugins/disk-usage" }, { "name": "system-monitor", "source": "./plugins/system-monitor" } ] } diff --git a/README.md b/README.md index bb4d21a..aaf8ac7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ bb plugin install system-monitor@charpeni | Plugin | Description | | ---------------------------------------- | ---------------------------------------------------------------------------------- | | [Dependabot](plugins/dependabot) | Review GitHub Dependabot alerts by dependency and send grouped fixes to BB agents. | +| [Disk Usage](plugins/disk-usage) | See what's taking up disk space on the bb server host, with drill-down. | | [System Monitor](plugins/system-monitor) | Live CPU, memory, disk, load, and uptime statistics for the bb server host. | Each plugin can also be installed directly, without the marketplace: diff --git a/plugins/disk-usage/.gitignore b/plugins/disk-usage/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/plugins/disk-usage/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/disk-usage/README.md b/plugins/disk-usage/README.md new file mode 100644 index 0000000..164e6dc --- /dev/null +++ b/plugins/disk-usage/README.md @@ -0,0 +1,31 @@ +# Disk Usage + +See what's taking up disk space on the bb server host, `ncdu`-style: scan a +directory, get its immediate children sorted by recursive size, and drill down +until you find the culprit. + +## What it does + +- **Disk Usage panel** — a sidebar panel that scans a directory (default: the + server home directory) and lists its children largest-first with share bars, + breadcrumbs, drill-down by click, and a free-form path input. +- **`bb disk-usage` CLI** — the same scan for agents and scripts: + + ```sh + bb disk-usage # scan the server home directory + bb disk-usage /var --top 10 # the 10 largest entries under /var + bb disk-usage ~/GitHub --json # machine-readable output + ``` + +## How sizes are measured + +- Sizes are **allocated disk blocks** (`blocks × 512`), not apparent size, so + sparse files are reported by what they actually occupy. +- Symlinks are **never followed** and count as zero bytes. +- Hardlinked files are counted **once** per scan. +- Unreadable entries (permissions, races) are skipped and counted, never fatal. +- A scan visits at most 500,000 entries; past that it stops descending and the + result is flagged as truncated. + +The scan runs on the machine hosting the bb server, so paths are server-host +paths — the same model as the System Monitor plugin. diff --git a/plugins/disk-usage/app.tsx b/plugins/disk-usage/app.tsx new file mode 100644 index 0000000..791d3f6 --- /dev/null +++ b/plugins/disk-usage/app.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { definePluginApp, useRpc } from "@get-bb/plugin-sdk/app"; +import type { PluginRpcResult } from "@get-bb/plugin-sdk/app"; +import type { rpcContract } from "./server.js"; + +type ScanResult = PluginRpcResult<(typeof rpcContract)["scan"]>; +type ScanEntry = ScanResult["entries"][number]; + +function formatBytes(bytes: number): string { + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[unit]}`; +} + +function formatCount(count: number): string { + return count.toLocaleString("en-US"); +} + +function barTone(share: number): string { + if (share >= 50) return "bg-destructive"; + if (share >= 25) return "bg-attention"; + return "bg-primary"; +} + +function breadcrumbsOf(path: string): { label: string; path: string }[] { + if (path === "/") return [{ label: "/", path: "/" }]; + const segments = path.split("/").filter(Boolean); + const crumbs = [{ label: "/", path: "/" }]; + let current = ""; + for (const segment of segments) { + current += `/${segment}`; + crumbs.push({ label: segment, path: current }); + } + return crumbs; +} + +function EntryRow({ + entry, + totalBytes, + onOpen, +}: { + entry: ScanEntry; + totalBytes: number; + onOpen: (name: string) => void; +}) { + const share = totalBytes > 0 ? (entry.bytes / totalBytes) * 100 : 0; + const isDirectory = entry.kind === "directory"; + + return ( +
  • +
    +
    + + {share.toFixed(1)}% + + + {formatBytes(entry.bytes)} + + {isDirectory ? ( + + ) : ( + {entry.name} + )} + + {isDirectory + ? `${formatCount(entry.entryCount)} entries` + : entry.kind === "other" + ? "special" + : ""} + +
    +
  • + ); +} + +function DiskUsagePanel() { + const rpc = useRpc(); + // null asks the server for its default (the server home directory). + const [path, setPath] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [isScanning, setIsScanning] = useState(true); + const [pathDraft, setPathDraft] = useState(""); + const [refreshNonce, setRefreshNonce] = useState(0); + + useEffect(() => { + let cancelled = false; + setIsScanning(true); + + rpc + .call("scan", { path }) + .then((next) => { + if (cancelled) return; + setResult(next); + setError(null); + }) + .catch((cause) => { + if (cancelled) return; + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (!cancelled) setIsScanning(false); + }); + + return () => { + cancelled = true; + }; + }, [rpc, path, refreshNonce]); + + const openChild = useCallback( + (name: string) => { + if (!result) return; + setPath(result.path === "/" ? `/${name}` : `${result.path}/${name}`); + }, + [result], + ); + + const crumbs = useMemo(() => (result ? breadcrumbsOf(result.path) : []), [result]); + + return ( +
    +
    +
    +

    + What's taking up space on the bb server host. Click a directory to drill in. +

    +
    { + event.preventDefault(); + const target = pathDraft.trim(); + if (target) { + setPath(target); + setPathDraft(""); + } + }} + > + setPathDraft(event.target.value)} + placeholder="Scan a path…" + spellCheck={false} + className="h-8 w-44 rounded-md border bg-card px-2.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + /> + +
    +
    + + {error && ( +
    + {error} +
    + )} + + {!result && !error && ( +
    +
    +
    +

    Scanning disk usage

    +

    + Walking the directory tree on the server host… +

    +
    +
    + )} + + {result && ( +
    +
    + +
    +

    + {formatBytes(result.totalBytes)} +

    +

    + {formatCount(result.entryCount)} entries · scanned in{" "} + {(result.durationMs / 1000).toFixed(1)}s + {result.skippedCount > 0 && ` · ${formatCount(result.skippedCount)} unreadable`} +

    +
    + {result.truncated && ( +

    + Scan hit the entry budget — sizes below this point are partial. +

    + )} +
    + +
      + {result.entries.map((entry) => ( + + ))} +
    + {result.entries.length === 0 && ( +

    + This directory is empty. +

    + )} + {result.omittedEntryCount > 0 && ( +

    + … and {formatCount(result.omittedEntryCount)} smaller entries not shown. +

    + )} +
    + )} +
    +
    + ); +} + +export default definePluginApp((app) => { + app.slots.navPanel({ + id: "disk-usage", + title: "Disk Usage", + icon: "Layers", + path: "usage", + component: DiskUsagePanel, + }); +}); diff --git a/plugins/disk-usage/assets/hard-drive.svg b/plugins/disk-usage/assets/hard-drive.svg new file mode 100644 index 0000000..0d14346 --- /dev/null +++ b/plugins/disk-usage/assets/hard-drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/disk-usage/package.json b/plugins/disk-usage/package.json new file mode 100644 index 0000000..6e680e1 --- /dev/null +++ b/plugins/disk-usage/package.json @@ -0,0 +1,63 @@ +{ + "name": "bb-plugin-disk-usage", + "version": "0.1.0", + "private": true, + "description": "See what's taking up disk space on the bb server host, with drill-down by directory.", + "keywords": [ + "bb-plugin", + "disk-usage", + "ncdu", + "storage" + ], + "homepage": "https://github.com/charpeni/bb-plugins/tree/main/plugins/disk-usage#readme", + "bugs": { + "url": "https://github.com/charpeni/bb-plugins/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/charpeni/bb-plugins.git", + "directory": "plugins/disk-usage" + }, + "files": [ + "dist", + "server.ts", + "scan.ts", + "app.tsx", + "assets", + "README.md" + ], + "type": "module", + "scripts": { + "build": "bb plugin build", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "0.4.6", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "@types/react": "^19.2.18", + "bb-app": "^0.38.0", + "better-sqlite3": "^13.0.3", + "cron-parser": "^5.10.0", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + }, + "engines": { + "bb": ">=0.38", + "bbPluginSdk": ">=0.4.6" + }, + "bb": { + "name": "Disk Usage", + "description": "See what's taking up disk space on the bb server host, with drill-down by directory.", + "branding": { + "icon": "./assets/hard-drive.svg" + }, + "server": "./server.ts", + "app": "./app.tsx" + } +} diff --git a/plugins/disk-usage/scan.ts b/plugins/disk-usage/scan.ts new file mode 100644 index 0000000..add62f1 --- /dev/null +++ b/plugins/disk-usage/scan.ts @@ -0,0 +1,185 @@ +import type { Dirent } from "node:fs"; +import { lstat, readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// Bounds a runaway scan (e.g. `/` on a busy host): once the budget is spent +// the walk stops descending and the result is flagged truncated. +export const MAX_VISITED_ENTRIES = 500_000; +export const MAX_RETURNED_ENTRIES = 100; + +export type ScanEntryKind = "directory" | "file" | "other"; + +export interface ScanEntry { + name: string; + kind: ScanEntryKind; + bytes: number; + entryCount: number; +} + +export interface ScanResult { + path: string; + parentPath: string | null; + totalBytes: number; + entryCount: number; + skippedCount: number; + truncated: boolean; + omittedEntryCount: number; + durationMs: number; + scannedAt: number; + entries: ScanEntry[]; +} + +interface WalkBudget { + remaining: number; + skipped: number; + truncated: boolean; + // Hardlinked inodes (nlink > 1) are counted once, keyed by dev:ino. + seenHardlinks: Set; +} + +async function fileBytes(path: string, budget: WalkBudget): Promise { + try { + const stats = await lstat(path); + if (stats.nlink > 1) { + const key = `${stats.dev}:${stats.ino}`; + if (budget.seenHardlinks.has(key)) return 0; + budget.seenHardlinks.add(key); + } + // Allocated blocks measure real disk usage (sparse files); fall back to + // apparent size on filesystems that report no blocks for inline data. + return stats.blocks > 0 ? stats.blocks * 512 : stats.size; + } catch { + budget.skipped += 1; + return 0; + } +} + +async function readEntries(path: string, budget: WalkBudget): Promise { + try { + return await readdir(path, { withFileTypes: true }); + } catch { + budget.skipped += 1; + return null; + } +} + +async function walkTree( + path: string, + budget: WalkBudget, +): Promise<{ bytes: number; entryCount: number }> { + const dirents = await readEntries(path, budget); + if (dirents === null) return { bytes: 0, entryCount: 0 }; + + let bytes = 0; + let entryCount = 0; + const subdirectories: string[] = []; + const files: string[] = []; + + for (const dirent of dirents) { + if (budget.remaining <= 0) { + budget.truncated = true; + break; + } + budget.remaining -= 1; + entryCount += 1; + // Symlinks are never followed; they and special files count as entries + // without measured bytes. + if (dirent.isDirectory()) subdirectories.push(join(path, dirent.name)); + else if (dirent.isFile()) files.push(join(path, dirent.name)); + } + + const sizes = await Promise.all(files.map((filePath) => fileBytes(filePath, budget))); + for (const size of sizes) bytes += size; + + for (const subdirectory of subdirectories) { + const sub = await walkTree(subdirectory, budget); + bytes += sub.bytes; + entryCount += sub.entryCount; + } + + return { bytes, entryCount }; +} + +export async function scanPath(requestedPath: string | null): Promise { + const startedAt = Date.now(); + const trimmed = requestedPath?.trim(); + const path = resolve(trimmed ? trimmed : homedir()); + + let rootStats; + try { + rootStats = await stat(path); + } catch { + throw new Error(`Cannot access ${path}`); + } + if (!rootStats.isDirectory()) throw new Error(`Not a directory: ${path}`); + + const budget: WalkBudget = { + remaining: MAX_VISITED_ENTRIES, + skipped: 0, + truncated: false, + seenHardlinks: new Set(), + }; + + const dirents = await readdir(path, { withFileTypes: true }); + const entries: ScanEntry[] = []; + let totalBytes = 0; + let entryCount = 0; + + for (const dirent of dirents) { + if (budget.remaining <= 0) { + budget.truncated = true; + break; + } + budget.remaining -= 1; + entryCount += 1; + const childPath = join(path, dirent.name); + + if (dirent.isDirectory()) { + const sub = await walkTree(childPath, budget); + entries.push({ + name: dirent.name, + kind: "directory", + bytes: sub.bytes, + entryCount: sub.entryCount, + }); + totalBytes += sub.bytes; + entryCount += sub.entryCount; + } else if (dirent.isFile()) { + const bytes = await fileBytes(childPath, budget); + entries.push({ name: dirent.name, kind: "file", bytes, entryCount: 0 }); + totalBytes += bytes; + } else { + entries.push({ name: dirent.name, kind: "other", bytes: 0, entryCount: 0 }); + } + } + + entries.sort((a, b) => b.bytes - a.bytes || a.name.localeCompare(b.name)); + const returned = entries.slice(0, MAX_RETURNED_ENTRIES); + const parentPath = dirname(path); + + return { + path, + parentPath: parentPath === path ? null : parentPath, + totalBytes, + entryCount, + skippedCount: budget.skipped, + truncated: budget.truncated, + omittedEntryCount: entries.length - returned.length, + durationMs: Date.now() - startedAt, + scannedAt: Date.now(), + entries: returned, + }; +} + +export function formatBytes(bytes: number): string { + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(digits)} ${units[unit]}`; +} diff --git a/plugins/disk-usage/server.test.ts b/plugins/disk-usage/server.test.ts new file mode 100644 index 0000000..57e01ad --- /dev/null +++ b/plugins/disk-usage/server.test.ts @@ -0,0 +1,89 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import plugin, { scanSchema } from "./server"; + +let root: string; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "bb-disk-usage-")); + await mkdir(join(root, "big")); + await writeFile(join(root, "big", "blob.bin"), Buffer.alloc(256 * 1024, 1)); + await mkdir(join(root, "small")); + await writeFile(join(root, "small", "note.txt"), "hello"); + await writeFile(join(root, "loose.bin"), Buffer.alloc(64 * 1024, 2)); + await symlink(join(root, "big"), join(root, "link")); +}); + +afterAll(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function loadPlugin() { + const host = createFakePluginHost({ pluginId: "disk-usage" }); + await plugin(host.bb); + return host; +} + +describe("Disk Usage", () => { + it("scans a directory over RPC with recursive per-child sizes", async () => { + const { harness } = await loadPlugin(); + const result = scanSchema.parse(await harness.callRpc("scan", { path: root })); + + expect(result.path).toBe(root); + expect(result.parentPath).toBe(tmpdir()); + expect(result.truncated).toBe(false); + // big/ (256K), loose.bin (64K), small/ (5B), link (0B, never followed) + expect(result.entries.map((entry) => entry.name)).toEqual([ + "big", + "loose.bin", + "small", + "link", + ]); + expect(result.entries[0]).toMatchObject({ kind: "directory", entryCount: 1 }); + expect(result.entries[0]!.bytes).toBeGreaterThanOrEqual(256 * 1024); + expect(result.entries[1]).toMatchObject({ kind: "file", entryCount: 0 }); + expect(result.entries[3]).toMatchObject({ name: "link", kind: "other", bytes: 0 }); + // 4 top-level entries + blob.bin + note.txt inside the two directories. + expect(result.entryCount).toBe(6); + const childSum = result.entries.reduce((sum, entry) => sum + entry.bytes, 0); + expect(result.totalBytes).toBe(childSum); + }); + + it("rejects a path that is not a directory", async () => { + const { harness } = await loadPlugin(); + await expect(harness.callRpc("scan", { path: join(root, "loose.bin") })).rejects.toThrow( + /Not a directory/, + ); + }); + + it("registers the disk-usage CLI with human and JSON output", async () => { + const { harness } = await loadPlugin(); + expect(harness.registrations.cli?.name).toBe("disk-usage"); + + const human = await harness.runCli([root, "--top", "2"]); + expect(human).toMatchObject({ exitCode: 0 }); + expect(human.stdout).toContain(root); + expect(human.stdout).toContain("big/"); + expect(human.stdout).not.toContain("small/"); + expect(human.stdout).toContain("smaller entries"); + + const json = await harness.runCli([root, "--json"]); + expect(json.exitCode).toBe(0); + expect(() => scanSchema.parse(JSON.parse(json.stdout))).not.toThrow(); + }); + + it("rejects unknown options and unreadable paths on the CLI", async () => { + const { harness } = await loadPlugin(); + + const unknown = await harness.runCli(["--nope"]); + expect(unknown.exitCode).toBe(2); + expect(unknown.stderr).toContain("Unknown option"); + + const missing = await harness.runCli([join(root, "does-not-exist")]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain("Cannot access"); + }); +}); diff --git a/plugins/disk-usage/server.ts b/plugins/disk-usage/server.ts new file mode 100644 index 0000000..e95b0aa --- /dev/null +++ b/plugins/disk-usage/server.ts @@ -0,0 +1,123 @@ +import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { formatBytes, scanPath, type ScanResult } from "./scan.js"; + +export const entrySchema = z.object({ + name: z.string(), + kind: z.enum(["directory", "file", "other"]), + bytes: z.number().int().nonnegative(), + entryCount: z.number().int().nonnegative(), +}); + +export const scanSchema = z.object({ + path: z.string(), + parentPath: z.string().nullable(), + totalBytes: z.number().int().nonnegative(), + entryCount: z.number().int().nonnegative(), + skippedCount: z.number().int().nonnegative(), + truncated: z.boolean(), + omittedEntryCount: z.number().int().nonnegative(), + durationMs: z.number().nonnegative(), + scannedAt: z.number().int().nonnegative(), + entries: z.array(entrySchema), +}); + +export const rpcContract = defineRpcContract({ + scan: { + input: z.object({ path: z.string().nullable() }).strict(), + output: scanSchema, + }, +}); + +function formatScan(result: ScanResult, top: number): string { + const seconds = (result.durationMs / 1000).toFixed(result.durationMs >= 10_000 ? 0 : 1); + const lines = [ + `${result.path} — ${formatBytes(result.totalBytes)} in ${result.entryCount.toLocaleString("en-US")} entries (scanned in ${seconds}s)`, + ]; + if (result.truncated) { + lines.push(`Warning: scan truncated after visiting the entry budget; sizes are partial.`); + } + if (result.skippedCount > 0) { + lines.push( + `Note: ${result.skippedCount.toLocaleString("en-US")} entries were unreadable and skipped.`, + ); + } + lines.push(""); + + for (const entry of result.entries.slice(0, top)) { + const share = result.totalBytes > 0 ? (entry.bytes / result.totalBytes) * 100 : 0; + const suffix = entry.kind === "directory" ? "/" : ""; + lines.push( + `${share.toFixed(1).padStart(5)}% ${formatBytes(entry.bytes).padStart(9)} ${entry.name}${suffix}`, + ); + } + const shown = Math.min(top, result.entries.length); + const hidden = result.entries.length - shown + result.omittedEntryCount; + if (hidden > 0) lines.push(`… and ${hidden.toLocaleString("en-US")} smaller entries`); + return lines.join("\n"); +} + +const USAGE = "Usage: bb disk-usage [path] [--top N] [--json]"; + +export default function plugin(bb: BbPluginApi) { + bb.rpc.register(rpcContract, { + scan: ({ path }) => scanPath(path), + }); + + bb.cli.register({ + name: "disk-usage", + summary: "Show what's taking up disk space on the bb server host", + commands: [ + { + name: "scan", + summary: + "Scan a directory (default: the server home directory) and list the largest entries", + usage: "bb disk-usage [path] [--top N] [--json]", + }, + ], + async run(argv) { + if (argv.includes("--help") || argv.includes("-h")) { + return { + exitCode: 0, + stdout: `${USAGE}\n\nScans the filesystem of the host running the bb server and lists the largest entries per directory. Symlinks are never followed.`, + }; + } + + let top = 20; + const positional: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]!; + if (argument === "--json") continue; + if (argument === "--top") { + const value = Number(argv[index + 1]); + if (!Number.isInteger(value) || value <= 0) { + return { exitCode: 2, stderr: `--top expects a positive integer\n${USAGE}` }; + } + top = value; + index += 1; + continue; + } + if (argument.startsWith("-")) { + return { exitCode: 2, stderr: `Unknown option: ${argument}\n${USAGE}` }; + } + positional.push(argument); + } + if (positional[0] === "scan") positional.shift(); + if (positional.length > 1) { + return { exitCode: 2, stderr: `Too many arguments: ${positional.join(" ")}\n${USAGE}` }; + } + + let result: ScanResult; + try { + result = await scanPath(positional[0] ?? null); + } catch (cause) { + return { exitCode: 1, stderr: cause instanceof Error ? cause.message : String(cause) }; + } + + return { + exitCode: 0, + stdout: argv.includes("--json") ? JSON.stringify(result, null, 2) : formatScan(result, top), + }; + }, + }); +} diff --git a/plugins/disk-usage/tsconfig.json b/plugins/disk-usage/tsconfig.json new file mode 100644 index 0000000..3b20d7b --- /dev/null +++ b/plugins/disk-usage/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM"], + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["app.tsx", "server.ts", "scan.ts", "server.test.ts", "vitest.config.ts"] +} diff --git a/plugins/disk-usage/vitest.config.ts b/plugins/disk-usage/vitest.config.ts new file mode 100644 index 0000000..9b92147 --- /dev/null +++ b/plugins/disk-usage/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: "bb-plugin-disk-usage", + silent: "passed-only", + include: ["**/*.test.ts"], + exclude: ["node_modules/**"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81f562c..5f5e25c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,40 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + plugins/disk-usage: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@get-bb/plugin-sdk': + specifier: 0.4.6 + version: 0.4.6(@types/better-sqlite3@7.6.13)(@types/react@19.2.18)(better-sqlite3@13.0.3)(cron-parser@5.10.0)(hono@4.13.2)(zod@4.4.3) + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + bb-app: + specifier: ^0.38.0 + version: 0.38.0(ws@8.21.3) + better-sqlite3: + specifier: ^13.0.3 + version: 13.0.3 + cron-parser: + specifier: ^5.10.0 + version: 5.10.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(vite@8.2.1(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + plugins/system-monitor: dependencies: zod: From f953d6102c8eb6d85c138e95c4354ccce2a7e400 Mon Sep 17 00:00:00 2001 From: Nicolas Charpentier Date: Mon, 17 Aug 2026 18:57:40 -0400 Subject: [PATCH 2/2] Parallelize Disk Usage scans, stream progress, and cache per path --- plugins/disk-usage/README.md | 15 +- plugins/disk-usage/app.tsx | 108 ++++++++++++-- plugins/disk-usage/scan.ts | 227 ++++++++++++++++++++++-------- plugins/disk-usage/server.test.ts | 50 ++++++- plugins/disk-usage/server.ts | 86 +++++++++-- 5 files changed, 398 insertions(+), 88 deletions(-) diff --git a/plugins/disk-usage/README.md b/plugins/disk-usage/README.md index 164e6dc..b7b2d55 100644 --- a/plugins/disk-usage/README.md +++ b/plugins/disk-usage/README.md @@ -8,13 +8,20 @@ until you find the culprit. - **Disk Usage panel** — a sidebar panel that scans a directory (default: the server home directory) and lists its children largest-first with share bars, - breadcrumbs, drill-down by click, and a free-form path input. + breadcrumbs, drill-down by click, and a free-form path input. While a scan + runs, a live progress card streams entries visited, bytes so far, and the + directory currently being walked. +- **Per-path cache** — the last result for each path is kept in memory, so + drilling back up (or reopening the panel) is instant; the header shows the + snapshot's age and **Rescan** forces a fresh walk. Concurrent requests for + the same path join a single walk. - **`bb disk-usage` CLI** — the same scan for agents and scripts: ```sh bb disk-usage # scan the server home directory bb disk-usage /var --top 10 # the 10 largest entries under /var bb disk-usage ~/GitHub --json # machine-readable output + bb disk-usage --refresh # bypass the per-path cache ``` ## How sizes are measured @@ -24,8 +31,10 @@ until you find the culprit. - Symlinks are **never followed** and count as zero bytes. - Hardlinked files are counted **once** per scan. - Unreadable entries (permissions, races) are skipped and counted, never fatal. -- A scan visits at most 500,000 entries; past that it stops descending and the - result is flagged as truncated. +- Directories are walked concurrently (bounded), and a scan visits at most + 500,000 entries; past that it stops descending and the result is flagged as + truncated, with the budget spread across the tree rather than exhausted + depth-first. The scan runs on the machine hosting the bb server, so paths are server-host paths — the same model as the System Monitor plugin. diff --git a/plugins/disk-usage/app.tsx b/plugins/disk-usage/app.tsx index 791d3f6..45f42f6 100644 --- a/plugins/disk-usage/app.tsx +++ b/plugins/disk-usage/app.tsx @@ -1,11 +1,32 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { definePluginApp, useRpc } from "@get-bb/plugin-sdk/app"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { definePluginApp, useRealtime, useRpc } from "@get-bb/plugin-sdk/app"; import type { PluginRpcResult } from "@get-bb/plugin-sdk/app"; import type { rpcContract } from "./server.js"; type ScanResult = PluginRpcResult<(typeof rpcContract)["scan"]>; type ScanEntry = ScanResult["entries"][number]; +interface ScanProgress { + scanId: string | null; + path: string; + visitedCount: number; + totalBytes: number; + skippedCount: number; + currentPath: string; + elapsedMs: number; +} + +function isScanProgress(payload: unknown): payload is ScanProgress { + if (typeof payload !== "object" || payload === null) return false; + const candidate = payload as Record; + return ( + typeof candidate.path === "string" && + typeof candidate.visitedCount === "number" && + typeof candidate.totalBytes === "number" && + typeof candidate.currentPath === "string" + ); +} + function formatBytes(bytes: number): string { const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; let value = bytes; @@ -22,6 +43,22 @@ function formatCount(count: number): string { return count.toLocaleString("en-US"); } +function formatAgo(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +function truncateMiddle(text: string, max: number): string { + if (text.length <= max) return text; + const half = Math.floor((max - 1) / 2); + return `${text.slice(0, half)}…${text.slice(-half)}`; +} + function barTone(share: number): string { if (share >= 50) return "bg-destructive"; if (share >= 25) return "bg-attention"; @@ -40,6 +77,20 @@ function breadcrumbsOf(path: string): { label: string; path: string }[] { return crumbs; } +function ProgressDetails({ progress }: { progress: ScanProgress }) { + return ( + <> + + {formatCount(progress.visitedCount)} entries · {formatBytes(progress.totalBytes)} so far ·{" "} + {(progress.elapsedMs / 1000).toFixed(0)}s + + + {truncateMiddle(progress.currentPath, 72)} + + + ); +} + function EntryRow({ entry, totalBytes, @@ -96,15 +147,29 @@ function DiskUsagePanel() { const [result, setResult] = useState(null); const [error, setError] = useState(null); const [isScanning, setIsScanning] = useState(true); + const [progress, setProgress] = useState(null); const [pathDraft, setPathDraft] = useState(""); const [refreshNonce, setRefreshNonce] = useState(0); + const scanIdRef = useRef(null); + const forceRefreshRef = useRef(false); + + useRealtime("progress", (payload) => { + if (isScanProgress(payload) && payload.scanId === scanIdRef.current) { + setProgress(payload); + } + }); useEffect(() => { let cancelled = false; + const scanId = crypto.randomUUID(); + scanIdRef.current = scanId; + const refresh = forceRefreshRef.current; + forceRefreshRef.current = false; setIsScanning(true); + setProgress(null); rpc - .call("scan", { path }) + .call("scan", { path, refresh, scanId }) .then((next) => { if (cancelled) return; setResult(next); @@ -115,7 +180,10 @@ function DiskUsagePanel() { setError(cause instanceof Error ? cause.message : String(cause)); }) .finally(() => { - if (!cancelled) setIsScanning(false); + if (cancelled) return; + setIsScanning(false); + setProgress(null); + scanIdRef.current = null; }); return () => { @@ -160,7 +228,10 @@ function DiskUsagePanel() { />