add-dashboard
Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots.
Install
npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-dashboard
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
git clone https://github.com/nanocoai/nanoclaw.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nanocoai/nanoclaw collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
/add-dashboard — NanoClaw Dashboard
Adds a local monitoring dashboard showing agent groups, sessions, channels, users, token usage, context windows, message activity, and real-time logs.
Architecture
NanoClaw (pusher) Dashboard (npm package)
┌──────────┐ POST JSON ┌──────────────┐
│ collects │ ────────────────→ │ /api/ingest │
│ DB data │ every 60s │ in-memory │
│ tails │ ────────────────→ │ /api/logs/ │
│ log file │ every 2s │ push │
└──────────┘ │ serves UI │
└──────────────┘
Steps
1. Install the npm package
pnpm install @nanoco/nanoclaw-dashboard
2. Copy the pusher module and its tests
Copy all three resource files into src/. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly.
.claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts
.claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts
.claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts
dashboard-pusher.test.ts— behavior: starts the pusher, posts a real snapshot to a fake dashboard.dashboard-wiring.test.ts— the code edit in step 3: asserts (via the TS AST) thatindex.tsdynamically imports./dashboard-pusher.jsandawaitsstartDashboard()as colocated statements ofmain(), after DB init and before the boot-complete log. Delete or misplace the edit and this goes red.
3. Wire into src/index.ts
This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in dashboard-pusher.ts, and the import is colocated with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove).
Add this block inside main(), just before the log.info('NanoClaw running') line:
// Dashboard (optional; no-ops without DASHBOARD_SECRET)
const { startDashboard } = await import('./dashboard-pusher.js');
await startDashboard();
startDashboard() reads DASHBOARD_SECRET/DASHBOARD_PORT itself and no-ops if the secret is unset, so nothing else in core needs to change.
4. Add environment variables to .env
DASHBOARD_SECRET=<generate-a-random-secret>
DASHBOARD_PORT=3100
Generate the secret: node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"
5. Build, test, and restart
Run from your NanoClaw project root:
pnpm run build
pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
Run build before the tests: it's what guards the @nanoco/nanoclaw-dashboard dependency. dashboard-pusher.ts reaches the package through await import('@nanoco/nanoclaw-dashboard'), so if step 4 was skipped, pnpm run build fails with TS2307: Cannot find module. The behavior test deliberately mocks that package — its startDashboard binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step.
6. Verify (runtime smoke check)
Once the service is restarted, confirm the dashboard is live:
curl -s http://localhost:3100/api/status
curl -s -H "Authorization: Bearer <secret>" http://localhost:3100/api/overview
Open http://localhost:3100/dashboard in a browser.
Dashboard Pages
| Page | Shows |
|---|---|
| Overview | Stats, token usage + cache hit rate, context windows, activity chart |
| Agent Groups | Sessions, wirings, destinations, members, admins |
| Sessions | Status, container state, context window usage bars |
| Channels | Live/offline status, messaging groups, sender policies |
| Messages | Per-session inbound/outbound messages |
| Users | Privilege hierarchy: owner > admin > member |
| Logs | Real-time log streaming with level filter |
Troubleshooting
- "No data yet": Wait 60s for first push, or check logs for push errors
- 401 errors: Verify
DASHBOARD_SECRETmatches in.env - Port conflict: Change
DASHBOARD_PORTin.env - No logs: Check
logs/nanoclaw.logexists
Removal
Reverse the apply steps. Safe to re-run even if some pieces are already gone.
rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts
pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true
Then, by hand, remove the single dashboard block the skill added to main() in src/index.ts (the // Dashboard (optional…) comment, the await import('./dashboard-pusher.js') line, and the await startDashboard(); call), and remove DASHBOARD_SECRET and DASHBOARD_PORT from .env.
pnpm run build
Files (nanoclaw)
-
resources
-
dashboard-pusher.test.ts 4.7 KB
/** * Integration test for the add-dashboard skill's integration point — * `startDashboard()`, the single call wired into src/index.ts. * * Archetype: in-process seam. It drives the *real* entry point against a * *real* (in-memory) central DB and a *fake* dashboard HTTP endpoint. The * only things stubbed are the external dashboard package (not needed to prove * the wiring) and env-file reads (so the test doesn't depend on the real * .env). This proves the skill works once applied: with a secret set it * collects a DB snapshot and posts it; with no secret it does nothing. * * Ships with the add-dashboard skill; apply copies it to src/ alongside the * pusher so it runs against the composed project. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'fs'; import http from 'http'; import type { AddressInfo } from 'net'; vi.mock('./config.js', async () => { const actual = await vi.importActual<typeof import('./config.js')>('./config.js'); return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-dashboard', ASSISTANT_NAME: 'TestBot' }; }); // The dashboard server package isn't needed to prove the integration point. vi.mock('@nanoco/nanoclaw-dashboard', () => ({ startDashboard: vi.fn() })); // Don't read the real .env — the test controls config via process.env only. vi.mock('./env.js', () => ({ readEnvFile: () => ({}) })); const TEST_DIR = '/tmp/nanoclaw-test-dashboard'; import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js'; import { startDashboard, stopDashboardPusher } from './dashboard-pusher.js'; function now(): string { return new Date().toISOString(); } interface CapturedPost { path: string; auth: string | undefined; body: Record<string, unknown>; } /** A fake dashboard server that captures the bodies the pusher POSTs. */ function startFakeDashboard(): Promise<{ port: number; posts: CapturedPost[]; close: () => Promise<void> }> { const posts: CapturedPost[] = []; const server = http.createServer((req, res) => { let raw = ''; req.on('data', (c) => { raw += c; }); req.on('end', () => { let body: Record<string, unknown> = {}; try { body = JSON.parse(raw); } catch { /* leave empty */ } posts.push({ path: req.url || '', auth: req.headers.authorization, body }); res.writeHead(200); res.end('ok'); }); }); return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const port = (server.address() as AddressInfo).port; resolve({ port, posts, close: () => new Promise<void>((r) => server.close(() => r())) }); }); }); } async function waitFor(pred: () => boolean, timeoutMs = 2000): Promise<void> { const start = Date.now(); while (!pred()) { if (Date.now() - start > timeoutMs) throw new Error('timed out waiting for condition'); await new Promise((r) => setTimeout(r, 20)); } } describe('add-dashboard integration point (startDashboard)', () => { beforeEach(async () => { if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); const db = await initTestDb(); await runMigrations(db); }); afterEach(async () => { stopDashboardPusher(); await closeDb(); delete process.env.DASHBOARD_SECRET; delete process.env.DASHBOARD_PORT; if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); }); it('posts a snapshot of the seeded state when DASHBOARD_SECRET is set', async () => { await createAgentGroup({ id: 'ag-1', name: 'Test Agent', folder: 'test-agent', agent_provider: null, created_at: now(), }); const dash = await startFakeDashboard(); process.env.DASHBOARD_SECRET = 'test-secret'; process.env.DASHBOARD_PORT = String(dash.port); await startDashboard(); await waitFor(() => dash.posts.some((p) => p.path === '/api/ingest')); const ingest = dash.posts.find((p) => p.path === '/api/ingest')!; expect(ingest.auth).toBe('Bearer test-secret'); expect(ingest.body.assistant_name).toBe('TestBot'); const groups = ingest.body.agent_groups as Array<{ id: string }>; expect(groups.map((g) => g.id)).toContain('ag-1'); for (const key of [ 'timestamp', 'sessions', 'channels', 'users', 'tokens', 'context_windows', 'activity', 'messages', ]) { expect(ingest.body).toHaveProperty(key); } await dash.close(); }); it('does nothing when DASHBOARD_SECRET is not set', async () => { const dash = await startFakeDashboard(); // no DASHBOARD_SECRET in env, and readEnvFile is stubbed to {} await startDashboard(); await new Promise((r) => setTimeout(r, 100)); expect(dash.posts).toHaveLength(0); await dash.close(); }); }); -
dashboard-pusher.ts 20.3 KB
/** * Dashboard pusher — collects NanoClaw state and POSTs a JSON * snapshot to the dashboard's /api/ingest endpoint every interval. */ import fs from 'fs'; import path from 'path'; import http from 'http'; import Database from 'better-sqlite3'; import { getAllAgentGroups, getAgentGroup } from './db/agent-groups.js'; import { getSessionsByAgentGroup } from './db/sessions.js'; import { getAllMessagingGroups, getMessagingGroupAgents } from './db/messaging-groups.js'; import { getDestinations } from './modules/agent-to-agent/db/agent-destinations.js'; import { getMembers } from './modules/permissions/db/agent-group-members.js'; import { getAllUsers, getUser } from './modules/permissions/db/users.js'; import { getUserRoles, getAdminsOfAgentGroup } from './modules/permissions/db/user-roles.js'; import { getUserDmsForUser } from './modules/permissions/db/user-dms.js'; import { getActiveAdapters, getRegisteredChannelNames } from './channels/channel-registry.js'; import { DATA_DIR, ASSISTANT_NAME } from './config.js'; import { getDb } from './db/connection.js'; import { getContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import { readEnvFile } from './env.js'; interface PusherConfig { port: number; secret: string; intervalMs?: number; } let timer: ReturnType<typeof setInterval> | null = null; let logTimer: ReturnType<typeof setInterval> | null = null; let logOffset = 0; export function startDashboardPusher(config: PusherConfig): void { const interval = config.intervalMs || 60000; // Push immediately on start, then on interval push(config).catch((err) => log.error('Dashboard push failed', { err })); timer = setInterval(() => { push(config).catch((err) => log.error('Dashboard push failed', { err })); }, interval); // Start log file tailing startLogTail(config); log.info('Dashboard pusher started', { intervalMs: interval }); } export function stopDashboardPusher(): void { if (timer) { clearInterval(timer); timer = null; } if (logTimer) { clearInterval(logTimer); logTimer = null; } } /** * Skill entry point — the single call wired into the host boot sequence. * * All of the dashboard's startup logic lives here, in the skill's own file, * so the integration point in src/index.ts is just `await startDashboard()`. * No-ops (and says so) when DASHBOARD_SECRET is unset. */ export async function startDashboard(): Promise<void> { const env = readEnvFile(['DASHBOARD_SECRET', 'DASHBOARD_PORT']); const secret = process.env.DASHBOARD_SECRET || env.DASHBOARD_SECRET; const port = parseInt(process.env.DASHBOARD_PORT || env.DASHBOARD_PORT || '3100', 10); if (!secret) { log.info('Dashboard disabled (no DASHBOARD_SECRET)'); return; } const { startDashboard: startServer } = await import('@nanoco/nanoclaw-dashboard'); startServer({ port, secret }); startDashboardPusher({ port, secret, intervalMs: 60000 }); } /** Fire-and-forget POST to the dashboard. */ function postJson(config: PusherConfig, urlPath: string, data: unknown): void { const body = JSON.stringify(data); const req = http.request({ hostname: '127.0.0.1', port: config.port, path: urlPath, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), Authorization: `Bearer ${config.secret}`, }, }); req.on('error', () => {}); req.write(body); req.end(); } const ANSI_RE = /\x1b\[[0-9;]*m/g; function startLogTail(config: PusherConfig): void { const logFile = path.resolve(process.cwd(), 'logs', 'nanoclaw.log'); if (!fs.existsSync(logFile)) return; // Send last 200 lines as backfill try { const allLines = fs .readFileSync(logFile, 'utf-8') .split('\n') .filter((l) => l.trim()); logOffset = fs.statSync(logFile).size; const tail = allLines.slice(-200).map((l) => l.replace(ANSI_RE, '')); if (tail.length > 0) postJson(config, '/api/logs/push', { lines: tail }); } catch { return; } // Poll every 2s for new lines logTimer = setInterval(() => { try { const stat = fs.statSync(logFile); if (stat.size <= logOffset) { logOffset = stat.size; return; } const buf = Buffer.alloc(stat.size - logOffset); const fd = fs.openSync(logFile, 'r'); fs.readSync(fd, buf, 0, buf.length, logOffset); fs.closeSync(fd); logOffset = stat.size; const lines = buf .toString() .split('\n') .filter((l) => l.trim()) .map((l) => l.replace(ANSI_RE, '')); if (lines.length > 0) postJson(config, '/api/logs/push', { lines }); } catch { /* ignore */ } }, 2000); } async function push(config: PusherConfig): Promise<void> { const snapshot = await collectSnapshot(); postJson(config, '/api/ingest', snapshot); log.debug('Dashboard snapshot pushed'); } async function collectSnapshot(): Promise<Record<string, unknown>> { const [agentGroups, sessions, channels, users, tokens, contextWindows] = await Promise.all([ collectAgentGroups(), collectSessions(), collectChannels(), collectUsers(), collectTokens(), collectContextWindows(), ]); return { timestamp: new Date().toISOString(), assistant_name: ASSISTANT_NAME, uptime: Math.floor(process.uptime()), agent_groups: agentGroups, sessions, channels, users, tokens, context_windows: contextWindows, activity: collectActivity(), messages: collectMessages(), }; } async function collectAgentGroups() { const groups = await getAllAgentGroups(); return Promise.all( groups.map(async (g) => { const [sessions, destinations, rawMembers, rawAdmins, containerConfig, wirings] = await Promise.all([ getSessionsByAgentGroup(g.id), getDestinations(g.id), getMembers(g.id), getAdminsOfAgentGroup(g.id), getContainerConfig(g.id), getDb().all<Record<string, unknown>>( `SELECT mga.*, mg.channel_type, mg.platform_id, mg.name as mg_name, mg.is_group, mg.unknown_sender_policy FROM messaging_group_agents mga JOIN messaging_groups mg ON mg.id = mga.messaging_group_id WHERE mga.agent_group_id = ?`, g.id, ), ]); const running = sessions.filter((s) => s.container_status === 'running' || s.container_status === 'idle'); const members = await Promise.all( rawMembers.map(async (m) => { const user = await getUser(m.user_id); return { ...m, display_name: user?.display_name ?? null }; }), ); const admins = await Promise.all( rawAdmins.map(async (a) => { const user = await getUser(a.user_id); return { ...a, display_name: user?.display_name ?? null }; }), ); return { id: g.id, name: g.name, folder: g.folder, agent_provider: g.agent_provider, container_config: containerConfig ?? null, sessionCount: sessions.length, runningSessions: running.length, wirings, destinations, members, admins, created_at: g.created_at, }; }), ); } async function collectSessions() { return getDb().all<Record<string, unknown>>( `SELECT s.*, ag.name as agent_group_name, ag.folder as agent_group_folder, mg.channel_type, mg.platform_id, mg.name as messaging_group_name FROM sessions s LEFT JOIN agent_groups ag ON ag.id = s.agent_group_id LEFT JOIN messaging_groups mg ON mg.id = s.messaging_group_id ORDER BY s.last_active DESC NULLS LAST`, ); } async function collectChannels() { const messagingGroups = await getAllMessagingGroups(); const liveAdapters = getActiveAdapters().map((a) => a.channelType); const registeredChannels = getRegisteredChannelNames(); const byType: Record<string, { channelType: string; isLive: boolean; isRegistered: boolean; groups: unknown[] }> = {}; for (const mg of messagingGroups) { if (!byType[mg.channel_type]) { byType[mg.channel_type] = { channelType: mg.channel_type, isLive: liveAdapters.includes(mg.channel_type), isRegistered: registeredChannels.includes(mg.channel_type), groups: [], }; } const wiringAgents = await getMessagingGroupAgents(mg.id); const agents = await Promise.all( wiringAgents.map(async (a) => { const group = await getAgentGroup(a.agent_group_id); return { agent_group_id: a.agent_group_id, agent_group_name: group?.name ?? null, priority: a.priority }; }), ); byType[mg.channel_type].groups.push({ messagingGroup: { id: mg.id, platform_id: mg.platform_id, name: mg.name, is_group: mg.is_group, unknown_sender_policy: (mg as unknown as Record<string, unknown>).unknown_sender_policy ?? 'strict', }, agents, }); } // Include live adapters with no messaging groups for (const ct of liveAdapters) { if (!byType[ct]) { byType[ct] = { channelType: ct, isLive: true, isRegistered: true, groups: [] }; } } return Object.values(byType).sort((a, b) => a.channelType.localeCompare(b.channelType)); } async function collectUsers() { const users = await getAllUsers(); return Promise.all( users.map(async (u) => { const [roles, dms, memberships] = await Promise.all([ getUserRoles(u.id), getUserDmsForUser(u.id), getDb().all<Record<string, unknown>>( `SELECT agm.agent_group_id, ag.name as agent_group_name FROM agent_group_members agm JOIN agent_groups ag ON ag.id = agm.agent_group_id WHERE agm.user_id = ?`, u.id, ), ]); let privilege = 'none'; if (roles.some((r) => r.role === 'owner')) privilege = 'owner'; else if (roles.some((r) => r.role === 'admin' && !r.agent_group_id)) privilege = 'global_admin'; else if (roles.some((r) => r.role === 'admin')) privilege = 'admin'; else if (memberships.length > 0) privilege = 'member'; return { id: u.id, kind: u.kind, display_name: u.display_name, privilege, roles, memberships, dmChannels: dms.map((d) => ({ channel_type: d.channel_type })), created_at: u.created_at, }; }), ); } async function collectTokens() { const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); const allEntries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; agentGroupId: string; }> = []; const agentGroups = await getAllAgentGroups(); const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); if (fs.existsSync(sessionsDir)) { for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { const entries = scanJsonlTokens(path.join(sessionsDir, agDir)); allEntries.push(...entries.map((e) => ({ ...e, agentGroupId: agDir }))); } } const byModel: Record< string, { requests: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; } > = {}; const byGroup: Record< string, { requests: number; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; name: string; } > = {}; const totals = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; for (const e of allEntries) { if (!byModel[e.model]) byModel[e.model] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }; byModel[e.model].requests++; byModel[e.model].inputTokens += e.inputTokens; byModel[e.model].outputTokens += e.outputTokens; byModel[e.model].cacheReadTokens += e.cacheReadTokens; byModel[e.model].cacheCreationTokens += e.cacheCreationTokens; if (!byGroup[e.agentGroupId]) byGroup[e.agentGroupId] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, name: nameMap.get(e.agentGroupId) || e.agentGroupId, }; byGroup[e.agentGroupId].requests++; byGroup[e.agentGroupId].inputTokens += e.inputTokens; byGroup[e.agentGroupId].outputTokens += e.outputTokens; byGroup[e.agentGroupId].cacheReadTokens += e.cacheReadTokens; byGroup[e.agentGroupId].cacheCreationTokens += e.cacheCreationTokens; totals.requests++; totals.inputTokens += e.inputTokens; totals.outputTokens += e.outputTokens; totals.cacheReadTokens += e.cacheReadTokens; totals.cacheCreationTokens += e.cacheCreationTokens; } return { totals, byModel, byGroup }; } function scanJsonlTokens(agentDir: string) { const claudeDir = path.join(agentDir, '.claude-shared', 'projects'); if (!fs.existsSync(claudeDir)) return []; const entries: Array<{ model: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheCreationTokens: number; }> = []; const walk = (dir: string): void => { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (entry.name.endsWith('.jsonl')) { try { for (const line of fs.readFileSync(full, 'utf-8').split('\n')) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r.type === 'assistant' && r.message?.usage) { const u = r.message.usage; entries.push({ model: r.message.model || 'unknown', inputTokens: u.input_tokens || 0, outputTokens: u.output_tokens || 0, cacheReadTokens: u.cache_read_input_tokens || 0, cacheCreationTokens: u.cache_creation_input_tokens || 0, }); } } catch { /* skip line */ } } } catch { /* skip file */ } } } } catch { /* skip dir */ } }; walk(claudeDir); return entries; } async function collectContextWindows() { const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); if (!fs.existsSync(sessionsDir)) return []; const results: unknown[] = []; const agentGroups = await getAllAgentGroups(); const nameMap = new Map(agentGroups.map((g) => [g.id, g.name])); for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { const claudeDir = path.join(sessionsDir, agDir, '.claude-shared', 'projects'); if (!fs.existsSync(claudeDir)) continue; // Find most recent JSONL const jsonlFiles: string[] = []; const walk = (dir: string): void => { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); else if (entry.name.endsWith('.jsonl')) jsonlFiles.push(full); } } catch { /* skip */ } }; walk(claudeDir); if (jsonlFiles.length === 0) continue; jsonlFiles.sort((a, b) => { try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return 0; } }); // Read last assistant turn from newest file const content = fs.readFileSync(jsonlFiles[0], 'utf-8'); const lines = content.split('\n'); for (let i = lines.length - 1; i >= 0; i--) { if (!lines[i].trim()) continue; try { const r = JSON.parse(lines[i]); if (r.type === 'assistant' && r.message?.usage) { const u = r.message.usage; const model = r.message.model || 'unknown'; const ctx = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); const max = 200000; results.push({ agentGroupId: agDir, agentGroupName: nameMap.get(agDir), sessionId: path.basename(jsonlFiles[0], '.jsonl'), model, contextTokens: ctx, outputTokens: u.output_tokens || 0, cacheReadTokens: u.cache_read_input_tokens || 0, cacheCreationTokens: u.cache_creation_input_tokens || 0, maxContext: max, usagePercent: max > 0 ? Math.round((ctx / max) * 100) : 0, timestamp: r.timestamp || '', }); break; } } catch { /* skip */ } } } return results; } // "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read // by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH". function localHourKey(d: Date): string { return d .toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }) .replace(' ', 'T'); } function collectActivity() { const now = Date.now(); const buckets: Record<string, { inbound: number; outbound: number }> = {}; for (let i = 0; i < 24; i++) { const key = localHourKey(new Date(now - i * 3600000)); buckets[key] = { inbound: 0, outbound: 0 }; } const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); if (!fs.existsSync(sessionsDir)) return toBucketArray(buckets); const cutoff = new Date(now - 86400000).toISOString(); try { for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { const agPath = path.join(sessionsDir, agDir); for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { for (const [dbName, direction] of [ ['outbound.db', 'outbound'], ['inbound.db', 'inbound'], ] as const) { const dbPath = path.join(agPath, sessDir, dbName); if (!fs.existsSync(dbPath)) continue; try { const db = new Database(dbPath, { readonly: true }); const table = direction === 'outbound' ? 'messages_out' : 'messages_in'; const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string; }[]; for (const row of rows) { const key = localHourKey(new Date(row.timestamp)); if (buckets[key]) buckets[key][direction]++; } db.close(); } catch { /* skip */ } } } } } catch { /* skip */ } return toBucketArray(buckets); } function toBucketArray(buckets: Record<string, { inbound: number; outbound: number }>) { return Object.entries(buckets) .map(([hour, counts]) => ({ hour, ...counts })) .sort((a, b) => a.hour.localeCompare(b.hour)); } function collectMessages() { const sessionsDir = path.join(DATA_DIR, 'v2-sessions'); if (!fs.existsSync(sessionsDir)) return []; const results: Array<{ agentGroupId: string; sessionId: string; inbound: unknown[]; outbound: unknown[] }> = []; const limit = 50; try { for (const agDir of fs.readdirSync(sessionsDir).filter((d) => d.startsWith('ag-'))) { const agPath = path.join(sessionsDir, agDir); for (const sessDir of fs.readdirSync(agPath).filter((d) => d.startsWith('sess-'))) { const inbound: unknown[] = []; const outbound: unknown[] = []; const inDbPath = path.join(agPath, sessDir, 'inbound.db'); if (fs.existsSync(inDbPath)) { try { const db = new Database(inDbPath, { readonly: true }); const rows = db.prepare('SELECT * FROM messages_in ORDER BY seq DESC LIMIT ?').all(limit); inbound.push(...(rows as unknown[]).reverse()); db.close(); } catch { /* skip */ } } const outDbPath = path.join(agPath, sessDir, 'outbound.db'); if (fs.existsSync(outDbPath)) { try { const db = new Database(outDbPath, { readonly: true }); const rows = db.prepare('SELECT * FROM messages_out ORDER BY seq DESC LIMIT ?').all(limit); outbound.push(...(rows as unknown[]).reverse()); db.close(); } catch { /* skip */ } } if (inbound.length > 0 || outbound.length > 0) { results.push({ agentGroupId: agDir, sessionId: sessDir, inbound, outbound }); } } } } catch { /* skip */ } return results; } -
dashboard-wiring.test.ts 3.9 KB
/** * Wiring test for the add-dashboard skill's code-edit integration point. * * The skill inserts one colocated block into src/index.ts (a dynamic * `import('./dashboard-pusher.js')` + `await startDashboard()` in main()). A * behavioral test of the pusher can't see whether that edit is actually * present and correctly placed — booting the real host is too heavy — so this * asserts the edit *structurally*, via the TypeScript AST. It verifies not * just that the call exists, but that: * - the pusher module is dynamically imported by its correct path, * - startDashboard() is awaited, * - both are DIRECT statements of main()'s body (right scope/level, not * nested or stranded in another function), * - the import precedes the call, and the whole block sits after DB init * and before the boot-complete log (right place). * * Delete or misplace the edit and this goes red. Combined with the unit test * (behavior of startDashboard) and the build (the call still type-checks), * the three together cover deletion, misplacement, drift, and behavior — for * a true code edit, with no registry required. * * Ships with the skill; apply copies it to src/. */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; import ts from 'typescript'; const indexPath = path.resolve(process.cwd(), 'src/index.ts'); const source = fs.readFileSync(indexPath, 'utf8'); const sf = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true); function mainBody(): ts.NodeArray<ts.Statement> { let body: ts.NodeArray<ts.Statement> | undefined; sf.forEachChild((n) => { if (ts.isFunctionDeclaration(n) && n.name?.text === 'main' && n.body) { body = n.body.statements; } }); if (!body) throw new Error('main() not found in src/index.ts'); return body; } function isAwaitedStartDashboard(s: ts.Statement): boolean { return ( ts.isExpressionStatement(s) && ts.isAwaitExpression(s.expression) && ts.isCallExpression(s.expression.expression) && ts.isIdentifier(s.expression.expression.expression) && s.expression.expression.expression.text === 'startDashboard' ); } /** `const { ... } = await import('./dashboard-pusher.js')` as a statement. */ function isDynamicImportOfPusher(s: ts.Statement): boolean { if (!ts.isVariableStatement(s)) return false; const init = s.declarationList.declarations[0]?.initializer; if (!init || !ts.isAwaitExpression(init) || !ts.isCallExpression(init.expression)) return false; const call = init.expression; if (call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false; const arg = call.arguments[0]; return !!arg && ts.isStringLiteral(arg) && arg.text === './dashboard-pusher.js'; } describe('add-dashboard wiring in src/index.ts', () => { it('dynamically imports the pusher and awaits startDashboard(), colocated in main(), after DB init and before the boot-complete log', () => { const stmts = mainBody(); const importIdx = stmts.findIndex(isDynamicImportOfPusher); const callIdx = stmts.findIndex(isAwaitedStartDashboard); const migrateIdx = stmts.findIndex((s) => s.getText(sf).includes('runMigrations(')); const runningIdx = stmts.findIndex((s) => s.getText(sf).includes("log.info('NanoClaw running')")); expect(importIdx, "dynamic import('./dashboard-pusher.js') must be a statement of main()").toBeGreaterThanOrEqual(0); expect(callIdx, 'await startDashboard() must be a statement of main()').toBeGreaterThanOrEqual(0); expect(migrateIdx, 'runMigrations() anchor not found').toBeGreaterThanOrEqual(0); expect(runningIdx, 'boot-complete log anchor not found').toBeGreaterThanOrEqual(0); expect(importIdx, 'the dynamic import must come after DB init').toBeGreaterThan(migrateIdx); expect(callIdx, 'the call must come after its import (colocated)').toBeGreaterThan(importIdx); expect(callIdx, 'startDashboard() must run before the boot-complete log').toBeLessThan(runningIdx); }); });
-
-
SKILL.md 5.5 KB
--- name: add-dashboard description: Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots. --- # /add-dashboard — NanoClaw Dashboard Adds a local monitoring dashboard showing agent groups, sessions, channels, users, token usage, context windows, message activity, and real-time logs. ## Architecture ``` NanoClaw (pusher) Dashboard (npm package) ┌──────────┐ POST JSON ┌──────────────┐ │ collects │ ────────────────→ │ /api/ingest │ │ DB data │ every 60s │ in-memory │ │ tails │ ────────────────→ │ /api/logs/ │ │ log file │ every 2s │ push │ └──────────┘ │ serves UI │ └──────────────┘ ``` ## Steps ### 1. Install the npm package ```bash pnpm install @nanoco/nanoclaw-dashboard ``` ### 2. Copy the pusher module and its tests Copy all three resource files into `src/`. The tests ship with the skill and run against the composed project — they're how you confirm the skill works and is wired in correctly. ``` .claude/skills/add-dashboard/resources/dashboard-pusher.ts → src/dashboard-pusher.ts .claude/skills/add-dashboard/resources/dashboard-pusher.test.ts → src/dashboard-pusher.test.ts .claude/skills/add-dashboard/resources/dashboard-wiring.test.ts → src/dashboard-wiring.test.ts ``` - `dashboard-pusher.test.ts` — behavior: starts the pusher, posts a real snapshot to a fake dashboard. - `dashboard-wiring.test.ts` — the code edit in step 3: asserts (via the TS AST) that `index.ts` dynamically imports `./dashboard-pusher.js` and `await`s `startDashboard()` as colocated statements of `main()`, after DB init and before the boot-complete log. Delete or misplace the edit and this goes red. ### 3. Wire into src/index.ts This is the skill's one integration point, and it's deliberately minimal and self-contained: all the startup logic lives in `dashboard-pusher.ts`, and the import is **colocated** with the call so the whole edit is a single block in one place — there's no separate top-of-file import to add (or to remember to remove). Add this block inside `main()`, just before the `log.info('NanoClaw running')` line: ```typescript // Dashboard (optional; no-ops without DASHBOARD_SECRET) const { startDashboard } = await import('./dashboard-pusher.js'); await startDashboard(); ``` `startDashboard()` reads `DASHBOARD_SECRET`/`DASHBOARD_PORT` itself and no-ops if the secret is unset, so nothing else in core needs to change. ### 4. Add environment variables to .env ``` DASHBOARD_SECRET=<generate-a-random-secret> DASHBOARD_PORT=3100 ``` Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes(16).toString('hex'))"` ### 5. Build, test, and restart Run from your NanoClaw project root: ```bash pnpm run build pnpm exec vitest run src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts # behavior + wiring source setup/lib/install-slug.sh systemctl --user restart $(systemd_unit) # Linux # or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS ``` Run `build` **before** the tests: it's what guards the `@nanoco/nanoclaw-dashboard` dependency. `dashboard-pusher.ts` reaches the package through `await import('@nanoco/nanoclaw-dashboard')`, so if step 4 was skipped, `pnpm run build` fails with `TS2307: Cannot find module`. The behavior test deliberately *mocks* that package — its `startDashboard` binds a real dashboard port, a side effect we don't want in a test — so the test alone would pass with the dependency missing. Build is therefore the leg that verifies the dependency is installed; keep it ahead of the tests in the validate step. ### 6. Verify (runtime smoke check) Once the service is restarted, confirm the dashboard is live: ```bash curl -s http://localhost:3100/api/status curl -s -H "Authorization: Bearer <secret>" http://localhost:3100/api/overview ``` Open `http://localhost:3100/dashboard` in a browser. ## Dashboard Pages | Page | Shows | |------|-------| | Overview | Stats, token usage + cache hit rate, context windows, activity chart | | Agent Groups | Sessions, wirings, destinations, members, admins | | Sessions | Status, container state, context window usage bars | | Channels | Live/offline status, messaging groups, sender policies | | Messages | Per-session inbound/outbound messages | | Users | Privilege hierarchy: owner > admin > member | | Logs | Real-time log streaming with level filter | ## Troubleshooting - **"No data yet"**: Wait 60s for first push, or check logs for push errors - **401 errors**: Verify `DASHBOARD_SECRET` matches in `.env` - **Port conflict**: Change `DASHBOARD_PORT` in `.env` - **No logs**: Check `logs/nanoclaw.log` exists ## Removal Reverse the apply steps. Safe to re-run even if some pieces are already gone. ```bash rm -f src/dashboard-pusher.ts src/dashboard-pusher.test.ts src/dashboard-wiring.test.ts pnpm uninstall @nanoco/nanoclaw-dashboard 2>/dev/null || true ``` Then, by hand, remove the single dashboard block the skill added to `main()` in `src/index.ts` (the `// Dashboard (optional…)` comment, the `await import('./dashboard-pusher.js')` line, and the `await startDashboard();` call), and remove `DASHBOARD_SECRET` and `DASHBOARD_PORT` from `.env`. ```bash pnpm run build ```
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.