From be30d2f0886f99d3831759f52ced6c16d486b299 Mon Sep 17 00:00:00 2001 From: duy Date: Thu, 29 Jan 2026 17:25:47 -0800 Subject: [PATCH] =?UTF-8?q?feat(boltbot):=20zero-friction=20onboarding=20?= =?UTF-8?q?=E2=80=94=20bundled,=20startup=20log,=20/audit,=20empty=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extensions/boltbot/dashboard/src/App.tsx | 2 + .../src/components/OnboardingCard.tsx | 35 +++++++++++++++ .../dashboard/src/components/ReceiptList.tsx | 25 +---------- .../dashboard/src/components/SessionView.tsx | 11 +++-- extensions/boltbot/index.ts | 28 ++++++++++++ .../boltbot/src/__tests__/integration.test.ts | 43 +++++++++++++++++-- extensions/boltbot/src/stores/local.ts | 13 +++++- src/gateway/server-startup-log.ts | 8 ++++ src/gateway/server.impl.ts | 1 + src/plugins/config-state.ts | 2 +- 10 files changed, 133 insertions(+), 35 deletions(-) create mode 100644 extensions/boltbot/dashboard/src/components/OnboardingCard.tsx diff --git a/extensions/boltbot/dashboard/src/App.tsx b/extensions/boltbot/dashboard/src/App.tsx index 4bbbfd627..4fbfec23b 100644 --- a/extensions/boltbot/dashboard/src/App.tsx +++ b/extensions/boltbot/dashboard/src/App.tsx @@ -169,6 +169,8 @@ export default function App() { )} diff --git a/extensions/boltbot/dashboard/src/components/OnboardingCard.tsx b/extensions/boltbot/dashboard/src/components/OnboardingCard.tsx new file mode 100644 index 000000000..aecf94b48 --- /dev/null +++ b/extensions/boltbot/dashboard/src/components/OnboardingCard.tsx @@ -0,0 +1,35 @@ +import { CheckCircle, Loader2, XCircle } from "lucide-react"; + +interface Props { + isLoading: boolean; + error: unknown; +} + +export default function OnboardingCard({ isLoading, error }: Props) { + if (isLoading) { + return ( +
+
+ ); + } + + if (error) { + return ( +
+
+ ); + } + + return ( +
+
+ ); +} diff --git a/extensions/boltbot/dashboard/src/components/ReceiptList.tsx b/extensions/boltbot/dashboard/src/components/ReceiptList.tsx index d371d7d18..eda98fe51 100644 --- a/extensions/boltbot/dashboard/src/components/ReceiptList.tsx +++ b/extensions/boltbot/dashboard/src/components/ReceiptList.tsx @@ -1,6 +1,7 @@ import { CheckCircle, XCircle, AlertTriangle } from "lucide-react"; import type { ActionReceipt } from "../types"; import { cn, formatRelativeTime } from "../utils"; +import OnboardingCard from "./OnboardingCard"; interface Props { receipts: ActionReceipt[]; @@ -29,30 +30,8 @@ export default function ReceiptList({ onLoadMore, loadingMore = false, }: Props) { - if (error) { - return ( -
- Failed to load receipts: {error instanceof Error ? error.message : "Unknown error"} -
- ); - } - - if (isLoading && receipts.length === 0) { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
- ); - } - if (receipts.length === 0) { - return ( -
- No actions recorded yet. Receipts appear here when your agent uses tools. -
- ); + return ; } return ( diff --git a/extensions/boltbot/dashboard/src/components/SessionView.tsx b/extensions/boltbot/dashboard/src/components/SessionView.tsx index a8942783d..d70cde423 100644 --- a/extensions/boltbot/dashboard/src/components/SessionView.tsx +++ b/extensions/boltbot/dashboard/src/components/SessionView.tsx @@ -2,10 +2,13 @@ import { useMemo, useState } from "react"; import { ChevronDown, CheckCircle, XCircle, AlertTriangle } from "lucide-react"; import type { ActionReceipt } from "../types"; import { cn, formatRelativeTime } from "../utils"; +import OnboardingCard from "./OnboardingCard"; interface Props { receipts: ActionReceipt[]; onSelectReceipt: (r: ActionReceipt) => void; + isLoading: boolean; + error: unknown; } const tierBadge: Record = { @@ -156,15 +159,11 @@ function SessionCard({ ); } -export default function SessionView({ receipts, onSelectReceipt }: Props) { +export default function SessionView({ receipts, onSelectReceipt, isLoading, error }: Props) { const groups = useMemo(() => groupBySession(receipts), [receipts]); if (groups.length === 0) { - return ( -
- No sessions recorded yet. Sessions appear here when your agent processes conversations. -
- ); + return ; } return ( diff --git a/extensions/boltbot/index.ts b/extensions/boltbot/index.ts index f2d3badfd..a7c17710d 100644 --- a/extensions/boltbot/index.ts +++ b/extensions/boltbot/index.ts @@ -5,6 +5,8 @@ import { createReceiptStore } from "./src/receipt-store.js"; import { registerBoltbotApi } from "./src/api.js"; import { registerDashboardRoutes } from "./src/dashboard-serve.js"; +const STATS_TIMEOUT_MS = 5000; + export default { id: "boltbot", name: "Boltbot โ€” Audit Dashboard", @@ -18,5 +20,31 @@ export default { registerBoltbotApi(api, store); registerDashboardRoutes(api); + + const dashboardUrl = process.env.BOLTBOT_DASHBOARD_URL || "/boltbot/dashboard/"; + + api.registerCommand({ + name: "audit", + description: "Show audit dashboard stats", + requireAuth: true, + acceptsArgs: false, + handler: async () => { + try { + const stats = await Promise.race([ + store.stats(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timeout")), STATS_TIMEOUT_MS), + ), + ]); + return { + text: `Boltbot Audit Dashboard\n${stats.total} actions ยท ${stats.anomalyCount} anomalies\n${dashboardUrl}`, + }; + } catch { + return { + text: `Boltbot Audit Dashboard\nStats unavailable โ€” check gateway logs\n${dashboardUrl}`, + }; + } + }, + }); }, }; diff --git a/extensions/boltbot/src/__tests__/integration.test.ts b/extensions/boltbot/src/__tests__/integration.test.ts index a7823bb9a..76d6b7f40 100644 --- a/extensions/boltbot/src/__tests__/integration.test.ts +++ b/extensions/boltbot/src/__tests__/integration.test.ts @@ -20,6 +20,17 @@ vi.mock("better-sqlite3", () => { return { default: MockDatabase }; }); +function createMockApi() { + let registeredCommand: any; + const mockApi = { + registerProvider: vi.fn(), + on: vi.fn(), + registerHttpRoute: vi.fn(), + registerCommand: vi.fn((cmd: any) => { registeredCommand = cmd; }), + }; + return { mockApi, getCommand: () => registeredCommand }; +} + describe("boltbot plugin integration", () => { it("plugin has correct id and name", async () => { const mod = await import("../../index.js"); @@ -42,18 +53,16 @@ describe("boltbot plugin integration", () => { registerProvider: vi.fn((p: any) => registered.providers.push(p)), on: vi.fn((name: string, handler: any) => registered.hooks.push({ name, handler })), registerHttpRoute: vi.fn((r: any) => registered.routes.push(r)), + registerCommand: vi.fn(), }; plugin.register(mockApi as any); - // No provider in audit-only mode expect(registered.providers).toHaveLength(0); - // after_tool_call hook registered expect(registered.hooks).toHaveLength(1); expect(registered.hooks[0].name).toBe("after_tool_call"); - // 4 HTTP routes: receipts, receipt, stats, dashboard expect(registered.routes).toHaveLength(4); const paths = registered.routes.map((r: any) => r.path); expect(paths).toContain("/boltbot/receipts"); @@ -71,6 +80,7 @@ describe("boltbot plugin integration", () => { registerProvider: vi.fn(), on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), registerHttpRoute: vi.fn(), + registerCommand: vi.fn(), }; plugin.register(mockApi as any); @@ -92,6 +102,7 @@ describe("boltbot plugin integration", () => { registerProvider: vi.fn(), on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), registerHttpRoute: vi.fn(), + registerCommand: vi.fn(), }; plugin.register(mockApi as any); @@ -102,4 +113,30 @@ describe("boltbot plugin integration", () => { { sessionKey: "test-sess", toolName: "web_search" }, ); }); + + it("registers /audit command", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + const { mockApi, getCommand } = createMockApi(); + + plugin.register(mockApi as any); + + const cmd = getCommand(); + expect(cmd).toBeDefined(); + expect(cmd.name).toBe("audit"); + expect(cmd.requireAuth).toBe(true); + }); + + it("/audit command returns stats", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + const { mockApi, getCommand } = createMockApi(); + + plugin.register(mockApi as any); + + const result = await getCommand().handler({}); + expect(result.text).toContain("Boltbot Audit Dashboard"); + expect(result.text).toContain("actions"); + expect(result.text).toContain("anomalies"); + }); }); diff --git a/extensions/boltbot/src/stores/local.ts b/extensions/boltbot/src/stores/local.ts index 37276af30..42100cd48 100644 --- a/extensions/boltbot/src/stores/local.ts +++ b/extensions/boltbot/src/stores/local.ts @@ -1,11 +1,20 @@ import Database from "better-sqlite3"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { mkdirSync } from "node:fs"; import type { ActionReceipt, ReceiptStore } from "../receipt-store.js"; export class LocalReceiptStore implements ReceiptStore { private db: Database.Database; - constructor(dbPath = "boltbot-receipts.db") { - this.db = new Database(dbPath); + static defaultDbPath(): string { + const dir = join(homedir(), ".clawdbot", "data"); + mkdirSync(dir, { recursive: true }); + return join(dir, "boltbot-receipts.db"); + } + + constructor(dbPath?: string) { + this.db = new Database(dbPath ?? LocalReceiptStore.defaultDbPath()); this.db.exec(` CREATE TABLE IF NOT EXISTS receipts ( id TEXT PRIMARY KEY, diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index cf6d2575c..b34f35f44 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -12,6 +12,7 @@ export function logGatewayStartup(params: { tlsEnabled?: boolean; log: { info: (msg: string, meta?: Record) => void }; isNixMode: boolean; + loadedPluginIds?: string[]; }) { const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({ cfg: params.cfg, @@ -37,4 +38,11 @@ export function logGatewayStartup(params: { if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); } + if (params.loadedPluginIds?.includes("boltbot")) { + const httpScheme = params.tlsEnabled ? "https" : "http"; + const dashboardUrl = `${httpScheme}://${formatHost(primaryHost)}:${params.port}/boltbot/dashboard/`; + params.log.info(`boltbot dashboard: ${dashboardUrl}`, { + consoleMessage: `boltbot dashboard: ${chalk.cyan(dashboardUrl)}`, + }); + } } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f641c4076..f2425bdbd 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -482,6 +482,7 @@ export async function startGatewayServer( tlsEnabled: gatewayTls.enabled, log, isNixMode, + loadedPluginIds: pluginRegistry.plugins.filter((p) => p.status === "loaded").map((p) => p.id), }); scheduleGatewayUpdateCheck({ cfg: cfgAtStart, log, isNixMode }); const tailscaleCleanup = await startGatewayTailscaleExposure({ diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index bf44b5fe4..dedf6a20d 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -13,7 +13,7 @@ export type NormalizedPluginsConfig = { entries: Record; }; -export const BUNDLED_ENABLED_BY_DEFAULT = new Set(); +export const BUNDLED_ENABLED_BY_DEFAULT = new Set(["boltbot"]); const normalizeList = (value: unknown): string[] => { if (!Array.isArray(value)) return [];