feat: upulse ui — local WebUI monitoring dashboard

Add a local WebUI dashboard for monitoring Pulse runtime state.
Run `upulse ui` to start at http://localhost:3140.

Features:
- Dashboard: daemon status, last tick, code rev, recent events timeline
- Vitals: sense key selection, canvas line charts, raw data view
- Rules: engine rule files in onion order
- Deploy: promote/rollback history with active version badge
- Event detail: click any event to view CAS object data

Technical:
- Zero dependencies: Bun HTTP server + single-file embedded HTML
- Linear-inspired dark theme, monospace accents
- 7 API endpoints reading from PulseStore
- Auto-polling (5s) on dashboard
- localhost-only, read-only, no auth needed

Files:
- commands/ui.ts: CLI command registration
- ui/server.ts: Bun HTTP server + API routes
- ui/dashboard.ts: embedded HTML/CSS/JS (874 lines)
- ui/server.test.ts: 8 API tests

Closes #50
This commit is contained in:
2026-04-15 01:02:12 +08:00
parent 20aa456b5f
commit 8cf19f58ab
5 changed files with 1299 additions and 0 deletions
+2
View File
@@ -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();
+46
View File
@@ -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 <number>', '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);
});
});
}
+874
View File
@@ -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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pulse UI</title>
<style>
/* ── Reset & Theme ──────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a26;
--bg-hover: #22222e;
--border: #2a2a3a;
--text-primary: #e8e8ef;
--text-secondary: #8888a0;
--text-dim: #55556a;
--accent: #7c5cfc;
--accent-dim: #5a3fd6;
--green: #34d399;
--red: #f87171;
--orange: #fb923c;
--blue: #60a5fa;
--yellow: #fbbf24;
--purple: #a78bfa;
--font: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--radius: 8px;
}
html { font-size: 14px; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
line-height: 1.5;
min-height: 100vh;
}
a { color: var(--accent); text-decoration: none; }
/* ── Layout ──────────────────────────────────────────── */
.app { display: flex; min-height: 100vh; }
.sidebar {
width: 200px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 20px 0;
flex-shrink: 0;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 10;
}
.main {
flex: 1;
margin-left: 200px;
padding: 24px 32px;
max-width: 1200px;
}
.logo {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.logo h1 {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.3px;
display: flex;
align-items: center;
gap: 8px;
}
.logo .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
transition: background 0.3s;
}
.logo .dot.running { background: var(--green); }
.logo .dot.stopped { background: var(--red); }
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
border-left: 2px solid transparent;
}
.nav-item:hover { color: var(--text-primary); background: var(--bg-hover); }
.nav-item.active {
color: var(--text-primary);
background: var(--bg-tertiary);
border-left-color: var(--accent);
}
.nav-icon { font-size: 16px; width: 20px; text-align: center; }
/* ── Page Header ──────────────────────────────────────── */
.page-header {
margin-bottom: 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.page-header h2 {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.4px;
}
.page-header .meta {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font);
}
/* ── Cards ────────────────────────────────────────────── */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim);
margin-bottom: 6px;
}
.card-value {
font-size: 24px;
font-weight: 700;
font-family: var(--font);
letter-spacing: -1px;
}
.card-sub {
font-size: 11px;
color: var(--text-secondary);
margin-top: 4px;
font-family: var(--font);
}
/* ── Status Badge ─────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.running { background: rgba(52,211,153,0.12); color: var(--green); }
.badge.stopped { background: rgba(248,113,113,0.12); color: var(--red); }
/* ── Events Table ────────────────────────────────────── */
.events-section { margin-top: 24px; }
.section-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim);
margin-bottom: 12px;
}
.event-list { display: flex; flex-direction: column; gap: 1px; }
.event-row {
display: grid;
grid-template-columns: 140px 80px 100px 1fr 80px;
gap: 12px;
padding: 10px 14px;
background: var(--bg-secondary);
border-left: 3px solid transparent;
font-size: 12px;
font-family: var(--font);
align-items: center;
cursor: pointer;
transition: background 0.1s;
}
.event-row:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.event-row:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.event-row:hover { background: var(--bg-hover); }
.event-row .time { color: var(--text-dim); }
.event-row .kind { font-weight: 600; }
.event-row .key { color: var(--text-secondary); }
.event-row .rev { color: var(--text-dim); font-size: 11px; }
.event-row .hash { color: var(--text-dim); font-size: 11px; }
/* Kind colors */
.kind-tick { color: var(--text-dim); border-left-color: var(--text-dim); }
.kind-collect { color: var(--blue); border-left-color: var(--blue); }
.kind-effect { color: var(--green); border-left-color: var(--green); }
.kind-error { color: var(--red); border-left-color: var(--red); }
.kind-promote { color: var(--purple); border-left-color: var(--purple); }
.kind-rollback { color: var(--orange); border-left-color: var(--orange); }
.kind-migrate { color: var(--yellow); border-left-color: var(--yellow); }
.kind-init { color: var(--accent); border-left-color: var(--accent); }
/* ── Detail Panel ────────────────────────────────────── */
.detail-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 100;
}
.detail-overlay.open { display: flex; justify-content: flex-end; }
.detail-panel {
width: 480px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
padding: 24px;
overflow-y: auto;
animation: slideIn 0.15s ease-out;
}
@keyframes slideIn { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.detail-panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-panel .close-btn {
cursor: pointer;
color: var(--text-dim);
font-size: 18px;
background: none;
border: none;
}
.detail-panel .close-btn:hover { color: var(--text-primary); }
.detail-field { margin-bottom: 12px; }
.detail-field label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-dim);
margin-bottom: 4px;
}
.detail-field pre {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
font-family: var(--font);
font-size: 12px;
color: var(--text-secondary);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
/* ── Vitals Chart ────────────────────────────────────── */
.vitals-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.vital-tab {
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: var(--font);
cursor: pointer;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
transition: all 0.15s;
}
.vital-tab:hover { color: var(--text-primary); border-color: var(--text-dim); }
.vital-tab.active { color: var(--accent); border-color: var(--accent); background: rgba(124,92,252,0.08); }
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
position: relative;
height: 300px;
}
.chart-container canvas { width: 100% !important; height: 100% !important; }
.chart-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
font-size: 13px;
}
.chart-legend {
display: flex;
gap: 16px;
margin-top: 12px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font);
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* ── Rules List ──────────────────────────────────────── */
.rule-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 6px;
font-family: var(--font);
font-size: 13px;
}
.rule-index {
color: var(--text-dim);
font-size: 11px;
width: 24px;
text-align: right;
}
.rule-name { color: var(--text-primary); }
/* ── Deploy Timeline ─────────────────────────────────── */
.deploy-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 6px;
font-family: var(--font);
font-size: 12px;
}
.deploy-icon { font-size: 18px; }
.deploy-info { flex: 1; }
.deploy-rev {
font-weight: 600;
color: var(--text-primary);
font-size: 13px;
}
.deploy-meta { color: var(--text-dim); margin-top: 2px; }
.deploy-time { color: var(--text-dim); font-size: 11px; }
.deploy-active {
padding: 2px 8px;
background: rgba(52,211,153,0.12);
color: var(--green);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
/* ── Empty State ─────────────────────────────────────── */
.empty {
text-align: center;
padding: 48px;
color: var(--text-dim);
}
.empty-icon { font-size: 32px; margin-bottom: 12px; }
.empty-text { font-size: 13px; }
/* ── Loading ─────────────────────────────────────────── */
.loading { color: var(--text-dim); font-size: 13px; padding: 24px; }
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 768px) {
.sidebar { width: 60px; }
.sidebar .nav-label, .sidebar .logo h1 span { display: none; }
.main { margin-left: 60px; padding: 16px; }
.event-row { grid-template-columns: 100px 60px 1fr; }
.event-row .rev, .event-row .hash { display: none; }
.cards { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<nav class="sidebar">
<div class="logo">
<h1><span class="dot" id="statusDot"></span> <span>Pulse</span></h1>
</div>
<div class="nav-item active" data-page="dashboard" onclick="switchPage('dashboard')">
<span class="nav-icon">◉</span>
<span class="nav-label">Dashboard</span>
</div>
<div class="nav-item" data-page="vitals" onclick="switchPage('vitals')">
<span class="nav-icon">♡</span>
<span class="nav-label">Vitals</span>
</div>
<div class="nav-item" data-page="rules" onclick="switchPage('rules')">
<span class="nav-icon">⚙</span>
<span class="nav-label">Rules</span>
</div>
<div class="nav-item" data-page="deploys" onclick="switchPage('deploys')">
<span class="nav-icon">↑</span>
<span class="nav-label">Deploy</span>
</div>
</nav>
<!-- Main Content -->
<div class="main">
<!-- Dashboard Page -->
<div id="page-dashboard" class="page">
<div class="page-header">
<h2>Dashboard</h2>
<span class="meta" id="lastUpdate"></span>
</div>
<div class="cards" id="statusCards"></div>
<div class="events-section">
<div class="section-title">Recent Events</div>
<div class="event-list" id="eventList">
<div class="loading">Loading events...</div>
</div>
</div>
</div>
<!-- Vitals Page -->
<div id="page-vitals" class="page" style="display:none">
<div class="page-header">
<h2>Vitals</h2>
</div>
<div class="vitals-tabs" id="vitalsTabs"></div>
<div class="chart-container" id="chartContainer">
<div class="chart-empty">Select a sense key to view history</div>
</div>
<div class="chart-legend" id="chartLegend"></div>
<div class="events-section" style="margin-top:24px">
<div class="section-title">Raw Data</div>
<div id="vitalsRaw"></div>
</div>
</div>
<!-- Rules Page -->
<div id="page-rules" class="page" style="display:none">
<div class="page-header">
<h2>Rules</h2>
<span class="meta">Onion order (first = outermost)</span>
</div>
<div id="rulesList">
<div class="loading">Loading rules...</div>
</div>
</div>
<!-- Deploys Page -->
<div id="page-deploys" class="page" style="display:none">
<div class="page-header">
<h2>Deploy History</h2>
</div>
<div id="deploysList">
<div class="loading">Loading deploy history...</div>
</div>
</div>
</div>
</div>
<!-- Detail Panel Overlay -->
<div class="detail-overlay" id="detailOverlay" onclick="closeDetail(event)">
<div class="detail-panel" id="detailPanel" onclick="event.stopPropagation()">
<h3>
<span id="detailTitle">Event Detail</span>
<button class="close-btn" onclick="closeDetail()">✕</button>
</h3>
<div id="detailContent"></div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────
let currentPage = 'dashboard';
let currentVitalKey = null;
let pollTimer = null;
// ── API ────────────────────────────────────────────────
async function api(path) {
const res = await fetch(path);
return res.json();
}
// ── Time Formatting ────────────────────────────────────
function timeAgo(ts) {
const diff = Date.now() - ts;
if (diff < 60000) return Math.floor(diff / 1000) + 's ago';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
}
function formatTime(ts) {
return new Date(ts).toLocaleTimeString('en-US', { hour12: false });
}
function formatDateTime(ts) {
const d = new Date(ts);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
d.toLocaleTimeString('en-US', { hour12: false });
}
// ── Navigation ─────────────────────────────────────────
function switchPage(page) {
currentPage = page;
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
document.getElementById('page-' + page).style.display = 'block';
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.querySelector('[data-page="' + page + '"]').classList.add('active');
if (page === 'vitals') loadSenseKeys();
if (page === 'rules') loadRules();
if (page === 'deploys') loadDeploys();
}
// ── Dashboard ──────────────────────────────────────────
async function loadDashboard() {
try {
const [status, eventsData] = await Promise.all([
api('/api/status'),
api('/api/events?limit=50'),
]);
// Status dot
const dot = document.getElementById('statusDot');
dot.className = 'dot ' + status.daemon;
// Last update
document.getElementById('lastUpdate').textContent = 'Updated ' + new Date().toLocaleTimeString('en-US', { hour12: false });
// Status cards
const cards = document.getElementById('statusCards');
let cardsHtml = '<div class="card"><div class="card-label">Daemon</div>' +
'<div class="card-value"><span class="badge ' + status.daemon + '">' +
'<span class="dot ' + status.daemon + '" style="width:6px;height:6px;border-radius:50%;display:inline-block"></span> ' +
status.daemon.toUpperCase() + '</span></div></div>';
if (status.lastTick) {
cardsHtml += '<div class="card"><div class="card-label">Last Tick</div>' +
'<div class="card-value">' + timeAgo(status.lastTick.occurredAt) + '</div>' +
'<div class="card-sub">' + formatTime(status.lastTick.occurredAt) + '</div></div>';
if (status.lastTick.meta) {
cardsHtml += '<div class="card"><div class="card-label">Tick Interval</div>' +
'<div class="card-value">' + (status.lastTick.meta.tick_ms / 1000).toFixed(1) + 's</div></div>';
}
}
if (status.currentCodeRev) {
cardsHtml += '<div class="card"><div class="card-label">Code Rev</div>' +
'<div class="card-value" style="font-size:16px">' + status.currentCodeRev + '</div></div>';
}
// Count recent events by kind
const events = eventsData.events || [];
const counts = {};
const oneHourAgo = Date.now() - 3600000;
for (const e of events) {
if (e.occurredAt >= oneHourAgo) {
counts[e.kind] = (counts[e.kind] || 0) + 1;
}
}
if (counts.error) {
cardsHtml += '<div class="card"><div class="card-label">Errors (1h)</div>' +
'<div class="card-value" style="color:var(--red)">' + counts.error + '</div></div>';
}
cards.innerHTML = cardsHtml;
// Events list
renderEvents(events);
} catch (err) {
document.getElementById('statusCards').innerHTML =
'<div class="card"><div class="card-label">Status</div>' +
'<div class="card-value" style="font-size:14px;color:var(--red)">Cannot connect</div>' +
'<div class="card-sub">Is upulse initialized?</div></div>';
}
}
function renderEvents(events) {
const list = document.getElementById('eventList');
if (!events || events.length === 0) {
list.innerHTML = '<div class="empty"><div class="empty-icon">◇</div>' +
'<div class="empty-text">No events yet. Start the daemon to begin collecting data.</div></div>';
return;
}
list.innerHTML = events.map(e => {
const kindClass = 'kind-' + (e.kind || 'tick');
return '<div class="event-row ' + kindClass + '" onclick="showEventDetail(\\'' + (e.id || '') + '\\', \\'' + (e.hash || '') + '\\')">' +
'<span class="time">' + formatDateTime(e.occurredAt) + '</span>' +
'<span class="kind">' + (e.kind || '-') + '</span>' +
'<span class="key">' + (e.key || '-') + '</span>' +
'<span class="rev">' + (e.codeRev || '-') + '</span>' +
'<span class="hash">' + (e.hash ? e.hash.slice(0, 8) + '…' : '-') + '</span>' +
'</div>';
}).join('');
}
// ── Event Detail ───────────────────────────────────────
async function showEventDetail(id, hash) {
const overlay = document.getElementById('detailOverlay');
const content = document.getElementById('detailContent');
overlay.classList.add('open');
let html = '<div class="detail-field"><label>Event ID</label><pre>' + id + '</pre></div>';
if (hash) {
try {
const obj = await api('/api/object/' + hash);
html += '<div class="detail-field"><label>Object Data</label>' +
'<pre>' + JSON.stringify(obj.data, null, 2) + '</pre></div>';
} catch {
html += '<div class="detail-field"><label>Object</label><pre>Failed to load</pre></div>';
}
}
content.innerHTML = html;
}
function closeDetail(event) {
if (event && event.target !== document.getElementById('detailOverlay')) return;
document.getElementById('detailOverlay').classList.remove('open');
}
// ── Vitals ─────────────────────────────────────────────
async function loadSenseKeys() {
const data = await api('/api/sense-keys');
const tabs = document.getElementById('vitalsTabs');
if (!data.keys || data.keys.length === 0) {
tabs.innerHTML = '<div class="empty-text" style="color:var(--text-dim)">No sense keys found. Start the daemon to collect data.</div>';
return;
}
tabs.innerHTML = data.keys.map(k =>
'<div class="vital-tab' + (k === currentVitalKey ? ' active' : '') + '" onclick="selectVital(\\'' + k + '\\')">' + k + '</div>'
).join('');
if (currentVitalKey && data.keys.includes(currentVitalKey)) {
loadVitalData(currentVitalKey);
}
}
async function selectVital(key) {
currentVitalKey = key;
document.querySelectorAll('.vital-tab').forEach(t => t.classList.remove('active'));
document.querySelector('.vital-tab[onclick*="' + key + '"]').classList.add('active');
await loadVitalData(key);
}
async function loadVitalData(key) {
const data = await api('/api/vitals/' + key + '?limit=100');
const container = document.getElementById('chartContainer');
const raw = document.getElementById('vitalsRaw');
const legend = document.getElementById('chartLegend');
if (!data.vitals || data.vitals.length === 0) {
container.innerHTML = '<div class="chart-empty">No vital data for "' + key + '"</div>';
raw.innerHTML = '';
legend.innerHTML = '';
return;
}
// Reverse to chronological order
const vitals = [...data.vitals].reverse();
// Extract numeric fields from first data point
const sample = vitals.find(v => v.data)?.data;
if (!sample || typeof sample !== 'object') {
container.innerHTML = '<div class="chart-empty">Data format not chartable</div>';
renderVitalsRaw(data.vitals);
return;
}
const fields = Object.keys(sample).filter(k => typeof sample[k] === 'number');
if (fields.length === 0) {
container.innerHTML = '<div class="chart-empty">No numeric fields to chart</div>';
renderVitalsRaw(data.vitals);
return;
}
// Draw chart
const colors = ['#7c5cfc', '#34d399', '#f87171', '#fb923c', '#60a5fa', '#fbbf24', '#a78bfa', '#ec4899'];
container.innerHTML = '<canvas id="vitalsCanvas"></canvas>';
const canvas = document.getElementById('vitalsCanvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
ctx.scale(dpr, dpr);
const W = rect.width;
const H = rect.height;
const pad = { top: 20, right: 20, bottom: 40, left: 50 };
const chartW = W - pad.left - pad.right;
const chartH = H - pad.top - pad.bottom;
// Find global min/max across all fields
let globalMin = Infinity, globalMax = -Infinity;
for (const v of vitals) {
if (!v.data) continue;
for (const f of fields) {
const val = v.data[f];
if (typeof val === 'number') {
globalMin = Math.min(globalMin, val);
globalMax = Math.max(globalMax, val);
}
}
}
if (globalMin === globalMax) { globalMin -= 1; globalMax += 1; }
const range = globalMax - globalMin;
globalMin -= range * 0.05;
globalMax += range * 0.05;
// Grid lines
ctx.strokeStyle = '#1a1a26';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad.top + (chartH / 4) * i;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
// Y-axis labels
const val = globalMax - ((globalMax - globalMin) / 4) * i;
ctx.fillStyle = '#55556a';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(val.toFixed(1), pad.left - 8, y + 3);
}
// X-axis labels
const step = Math.max(1, Math.floor(vitals.length / 6));
ctx.textAlign = 'center';
for (let i = 0; i < vitals.length; i += step) {
const x = pad.left + (chartW / (vitals.length - 1)) * i;
ctx.fillStyle = '#55556a';
ctx.font = '10px monospace';
ctx.fillText(formatTime(vitals[i].occurredAt), x, H - pad.bottom + 20);
}
// Draw lines for each field
fields.forEach((field, fi) => {
const color = colors[fi % colors.length];
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
let started = false;
for (let i = 0; i < vitals.length; i++) {
const val = vitals[i].data?.[field];
if (typeof val !== 'number') continue;
const x = pad.left + (chartW / Math.max(vitals.length - 1, 1)) * i;
const y = pad.top + chartH - ((val - globalMin) / (globalMax - globalMin)) * chartH;
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
}
ctx.stroke();
});
// Legend
legend.innerHTML = fields.map((f, i) =>
'<div class="legend-item"><span class="legend-dot" style="background:' + colors[i % colors.length] + '"></span>' + f + '</div>'
).join('');
renderVitalsRaw(data.vitals.slice(0, 10));
}
function renderVitalsRaw(vitals) {
const raw = document.getElementById('vitalsRaw');
if (!vitals || vitals.length === 0) {
raw.innerHTML = '<div class="empty-text" style="color:var(--text-dim)">No data</div>';
return;
}
raw.innerHTML = vitals.map(v =>
'<div class="event-row" style="grid-template-columns: 140px 1fr">' +
'<span class="time">' + formatDateTime(v.occurredAt) + '</span>' +
'<span class="key" style="font-family:var(--font);font-size:11px">' +
(v.data ? JSON.stringify(v.data) : '-') + '</span></div>'
).join('');
}
// ── Rules ──────────────────────────────────────────────
async function loadRules() {
const data = await api('/api/rules');
const list = document.getElementById('rulesList');
if (!data.rules || data.rules.length === 0) {
list.innerHTML = '<div class="empty"><div class="empty-icon">⚙</div>' +
'<div class="empty-text">No rules found in engine directory.</div></div>';
return;
}
list.innerHTML = data.rules.map(r =>
'<div class="rule-row">' +
'<span class="rule-index">' + r.index + '</span>' +
'<span class="rule-name">' + r.filename + '</span></div>'
).join('');
}
// ── Deploys ────────────────────────────────────────────
async function loadDeploys() {
const data = await api('/api/deploys');
const list = document.getElementById('deploysList');
if (!data.deploys || data.deploys.length === 0) {
list.innerHTML = '<div class="empty"><div class="empty-icon">↑</div>' +
'<div class="empty-text">No deploy history. Use "upulse deploy promote" to create one.</div></div>';
return;
}
list.innerHTML = data.deploys.map(d => {
const isPromote = d.kind === 'promote';
const icon = isPromote ? '⬆' : '⬇';
const isActive = d.codeRev === data.activeCodeRev && isPromote;
const fromRev = d.meta?.from || d.meta?.to || '-';
return '<div class="deploy-row">' +
'<span class="deploy-icon">' + icon + '</span>' +
'<div class="deploy-info">' +
'<div class="deploy-rev">' + d.kind.toUpperCase() + ' → ' + (d.codeRev || '?') + '</div>' +
'<div class="deploy-meta">from ' + fromRev + '</div></div>' +
'<span class="deploy-time">' + formatDateTime(d.occurredAt) + '</span>' +
(isActive ? '<span class="deploy-active">active</span>' : '') +
'</div>';
}).join('');
}
// ── Polling ────────────────────────────────────────────
function startPolling() {
loadDashboard();
pollTimer = setInterval(() => {
if (currentPage === 'dashboard') loadDashboard();
}, 5000);
}
// ── Init ───────────────────────────────────────────────
startPolling();
</script>
</body>
</html>`;
+151
View File
@@ -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);
});
});
+226
View File
@@ -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);
},
});
}