Merge pull request #52 from oc-xiaoju/feat/ui
feat: upulse ui — local WebUI monitoring dashboard
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user