Claude Skill

add-dashboard

Add a monitoring dashboard to NanoClaw. Installs @nanoco/nanoclaw-dashboard and a pusher that sends periodic JSON snapshots.

LLM Mart · 0 points · 15 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download nanocoai-nanoclaw-.claude_skills_add-dashboard-ad8837c.zip · 11 KB
Part of nanocoai/nanoclaw — 49 skills

Install

skills CLI npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-dashboard
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
Git 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) that index.ts dynamically imports ./dashboard-pusher.js and awaits 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:

  // 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_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.

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.

No comments yet.

Reviews (0)

No reviews yet.

Related