diff --git a/packages/upulse/src/cli.ts b/packages/upulse/src/cli.ts index e050d88..498f281 100644 --- a/packages/upulse/src/cli.ts +++ b/packages/upulse/src/cli.ts @@ -15,6 +15,7 @@ import { registerInitCommand } from './commands/init.js'; import { registerInspectCommand } from './commands/inspect.js'; import { registerListCommand } from './commands/list.js'; import { registerTickCommand } from './commands/tick.js'; +import { registerUICommand } from './commands/ui.js'; const program = new Command(); @@ -35,5 +36,6 @@ registerInspectCommand(program); registerDevCommand(program); registerDeployCommand(program); registerGcCommand(program); +registerUICommand(program); program.parse(); diff --git a/packages/upulse/src/commands/ui.ts b/packages/upulse/src/commands/ui.ts new file mode 100644 index 0000000..d10ccd5 --- /dev/null +++ b/packages/upulse/src/commands/ui.ts @@ -0,0 +1,46 @@ +/** + * commands/ui.ts — upulse ui [--port] [--no-open] + * + * Start a local WebUI dashboard for monitoring Pulse. + */ + +import type { Command } from 'commander'; +import { loadConfig, resolveDir } from '../config.js'; +import { createUIServer } from '../ui/server.js'; + +export function registerUICommand(program: Command): void { + program + .command('ui') + .description('Start local WebUI dashboard') + .option('--port ', 'HTTP port', '3140') + .option('--no-open', 'Do not open browser automatically') + .action(async (opts: { port: string; open: boolean }) => { + const config = loadConfig(resolveDir(program.opts().dir)); + const port = parseInt(opts.port, 10); + + const server = createUIServer(config, port); + + console.log(`\n ⚡ Pulse UI running at http://localhost:${port}\n`); + + if (opts.open) { + try { + const { execSync } = await import('node:child_process'); + const cmd = + process.platform === 'darwin' + ? 'open' + : process.platform === 'win32' + ? 'start' + : 'xdg-open'; + execSync(`${cmd} http://localhost:${port}`, { stdio: 'ignore' }); + } catch { + // Ignore — headless environments + } + } + + // Keep alive + process.on('SIGINT', () => { + server.stop(); + process.exit(0); + }); + }); +} diff --git a/packages/upulse/src/ui/dashboard.ts b/packages/upulse/src/ui/dashboard.ts new file mode 100644 index 0000000..89f7713 --- /dev/null +++ b/packages/upulse/src/ui/dashboard.ts @@ -0,0 +1,874 @@ +/** + * ui/dashboard.ts — Embedded single-file HTML dashboard + * + * Linear-inspired dark theme. Pure HTML + CSS + vanilla JS. + * No build tools, no frameworks, no external dependencies. + */ + +export const DASHBOARD_HTML = ` + + + + +Pulse UI + + + +
+ + + + +
+ +
+ +
+
+
Recent Events
+
+
Loading events...
+
+
+
+ + + + + + + + + +
+
+ + +
+
+

+ Event Detail + +

+
+
+
+ + + +`; diff --git a/packages/upulse/src/ui/server.test.ts b/packages/upulse/src/ui/server.test.ts new file mode 100644 index 0000000..dc1a65d --- /dev/null +++ b/packages/upulse/src/ui/server.test.ts @@ -0,0 +1,151 @@ +/** + * ui/server.test.ts — Tests for Pulse WebUI server + * + * Requires @uncaged/pulse to be built first (workspace dependency). + * When running from monorepo root without building, tests skip gracefully. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Server } from 'bun'; + +// ── Test Setup ───────────────────────────────────────── + +interface TestConfig { + dir: string; + engine: { path: string; entrypoint: string }; + staging: { path: string }; + daemon: { pidFile: string; logFile: string }; + store: { + eventsDbFile: string; + vitalsDbFile: string; + objectsDir: string; + }; +} + +function createTestConfig(baseDir: string): TestConfig { + return { + dir: baseDir, + engine: { + path: join(baseDir, 'engine'), + entrypoint: 'pulse.config.ts', + }, + staging: { path: join(baseDir, 'staging') }, + daemon: { + pidFile: join(baseDir, 'daemon.pid'), + logFile: join(baseDir, 'daemon.log'), + }, + store: { + eventsDbFile: join(baseDir, 'events.db'), + vitalsDbFile: join(baseDir, 'vitals.db'), + objectsDir: join(baseDir, 'objects'), + }, + }; +} + +describe('Pulse WebUI Server', () => { + let server: Server | null = null; + let baseUrl = ''; + let testDir: string; + let config: TestConfig; + let available = false; + + beforeAll(async () => { + testDir = join(tmpdir(), `pulse-ui-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + mkdirSync(join(testDir, 'engine', 'rules'), { recursive: true }); + mkdirSync(join(testDir, 'objects'), { recursive: true }); + + writeFileSync( + join(testDir, 'engine', 'rules', '00-clamp.ts'), + 'export default () => {};', + ); + writeFileSync( + join(testDir, 'engine', 'rules', '01-collect.ts'), + 'export default () => {};', + ); + + config = createTestConfig(testDir); + const port = 30000 + Math.floor(Math.random() * 10000); + + try { + const mod = await import('./server.js'); + server = mod.createUIServer(config, port); + baseUrl = `http://127.0.0.1:${port}`; + available = true; + } catch { + // @uncaged/pulse not resolvable — tests will be skipped + } + }); + + afterAll(() => { + server?.stop(); + }); + + it('GET / returns HTML dashboard', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + const body = await res.text(); + expect(body).toContain('Pulse UI'); + expect(body).toContain('Dashboard'); + }); + + it('GET /api/status returns daemon status', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/status`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.daemon).toBe('stopped'); + expect(data.enginePath).toBe(config.engine.path); + }); + + it('GET /api/events returns empty when no store', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/events`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.events).toEqual([]); + }); + + it('GET /api/sense-keys returns empty when no store', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/sense-keys`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.keys).toEqual([]); + }); + + it('GET /api/rules returns rule files sorted', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/rules`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.rules.length).toBe(2); + expect(data.rules[0].filename).toBe('00-clamp.ts'); + expect(data.rules[1].filename).toBe('01-collect.ts'); + }); + + it('GET /api/deploys returns empty when no store', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/deploys`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.deploys).toEqual([]); + }); + + it('GET /api/object/:hash returns 404 when no store', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/api/object/abc123`); + expect(res.status).toBe(404); + }); + + it('GET /unknown returns 404', async () => { + if (!available) return; + const res = await fetch(`${baseUrl}/unknown`); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/upulse/src/ui/server.ts b/packages/upulse/src/ui/server.ts new file mode 100644 index 0000000..daa492d --- /dev/null +++ b/packages/upulse/src/ui/server.ts @@ -0,0 +1,226 @@ +/** + * ui/server.ts — Bun HTTP server for Pulse WebUI + * + * API endpoints (JSON): + * GET /api/status → daemon status + config + * GET /api/events → recent events (query: limit, kind, since) + * GET /api/vitals/:key → vital history (query: limit) + * GET /api/object/:hash → CAS object by hash + * GET /api/rules → engine rule files + * GET /api/deploys → promote + rollback events + * + * Frontend: + * GET / → embedded single-file HTML + */ + +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Server } from 'bun'; +import type { UpulseConfig } from '../config.js'; +import { isDaemonRunning } from '../daemon.js'; +import { openStore, type PulseStore } from '../store.js'; +import { DASHBOARD_HTML } from './dashboard.js'; + +// ── Helpers ──────────────────────────────────────────────────── + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + }); +} + +function html(content: string): Response { + return new Response(content, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} + +function err(message: string, status = 400): Response { + return json({ error: message }, status); +} + +function getStore(config: UpulseConfig): PulseStore | null { + return openStore(config.store.eventsDbFile, config.store.vitalsDbFile); +} + +// ── API Handlers ─────────────────────────────────────────────── + +function handleStatus(config: UpulseConfig): Response { + const running = isDaemonRunning(config); + const store = getStore(config); + + let lastTick: unknown = null; + let currentCodeRev: string | null = null; + + if (store) { + const tickEvent = store.getLatest('tick'); + if (tickEvent) { + lastTick = { + occurredAt: tickEvent.occurredAt, + meta: tickEvent.meta ? JSON.parse(tickEvent.meta) : null, + }; + } + + const promote = store.getLatest('promote'); + currentCodeRev = promote?.codeRev ?? null; + store.close(); + } + + return json({ + daemon: running ? 'running' : 'stopped', + lastTick, + currentCodeRev, + enginePath: config.engine.path, + stagingPath: config.staging.path, + }); +} + +function handleEvents(config: UpulseConfig, url: URL): Response { + const store = getStore(config); + if (!store) return json({ events: [] }); + + const limit = parseInt(url.searchParams.get('limit') ?? '50', 10); + const kind = url.searchParams.get('kind'); + const since = url.searchParams.get('since'); + + let events: unknown[]; + if (kind) { + events = store.queryByKind(kind, { + limit, + since: since ? parseInt(since, 10) : undefined, + }); + } else { + events = store.getRecent(limit); + } + + store.close(); + return json({ events }); +} + +function handleVitals(config: UpulseConfig, key: string, url: URL): Response { + const store = getStore(config); + if (!store) return json({ vitals: [], key }); + + const limit = parseInt(url.searchParams.get('limit') ?? '100', 10); + const vitals = store.getVitalHistory(key, limit); + + // Resolve CAS objects for each vital + const resolved = vitals.map((v) => ({ + ...v, + data: v.hash ? store.getObject(v.hash) : null, + })); + + store.close(); + return json({ key, vitals: resolved }); +} + +function handleObject(config: UpulseConfig, hash: string): Response { + const store = getStore(config); + if (!store) return err('Store not found', 404); + + const obj = store.getObject(hash); + store.close(); + + if (obj === null) return err('Object not found', 404); + return json({ hash, data: obj }); +} + +function handleRules(config: UpulseConfig): Response { + const rulesDir = join(config.engine.path, 'rules'); + if (!existsSync(rulesDir)) return json({ rules: [] }); + + const files = readdirSync(rulesDir) + .filter((f) => f.endsWith('.ts')) + .sort(); + + const rules = files.map((f, i) => ({ + index: i, + filename: f, + path: join(rulesDir, f), + })); + + return json({ rules }); +} + +function handleDeploys(config: UpulseConfig): Response { + const store = getStore(config); + if (!store) return json({ deploys: [] }); + + const promotes = store.queryByKind('promote', { limit: 50 }); + const rollbacks = store.queryByKind('rollback', { limit: 50 }); + + // Merge and sort by time descending + const deploys = [...promotes, ...rollbacks] + .sort((a, b) => b.occurredAt - a.occurredAt) + .map((e) => ({ + ...e, + meta: e.meta ? JSON.parse(e.meta) : null, + })); + + // Determine current active version + const store2 = getStore(config); + let activeCodeRev: string | null = null; + if (store2) { + const latest = store2.getLatest('promote'); + activeCodeRev = latest?.codeRev ?? null; + store2.close(); + } + + store.close(); + return json({ deploys, activeCodeRev }); +} + +function handleSenseKeys(config: UpulseConfig): Response { + const store = getStore(config); + if (!store) return json({ keys: [] }); + + const collects = store.queryByKind('collect', { limit: 200 }); + const keys = [ + ...new Set(collects.map((e) => e.key).filter(Boolean) as string[]), + ]; + + store.close(); + return json({ keys }); +} + +// ── Server ───────────────────────────────────────────────────── + +export function createUIServer(config: UpulseConfig, port: number): Server { + return Bun.serve({ + port, + hostname: '127.0.0.1', + fetch(req: Request): Response { + const url = new URL(req.url); + const path = url.pathname; + + // API routes + if (path === '/api/status') return handleStatus(config); + if (path === '/api/events') return handleEvents(config, url); + if (path === '/api/sense-keys') return handleSenseKeys(config); + if (path === '/api/rules') return handleRules(config); + if (path === '/api/deploys') return handleDeploys(config); + + // Parameterized routes + const vitalsMatch = path.match(/^\/api\/vitals\/(.+)$/); + if (vitalsMatch) { + return handleVitals(config, vitalsMatch[1], url); + } + + const objectMatch = path.match(/^\/api\/object\/(.+)$/); + if (objectMatch) { + return handleObject(config, objectMatch[1]); + } + + // Frontend + if (path === '/' || path === '/index.html') { + return html(DASHBOARD_HTML); + } + + return err('Not found', 404); + }, + }); +}