AO-14: Refactor DashboardService - split UI assets into separate files for better maintainability

This commit is contained in:
larchanka
2026-02-23 08:11:47 +01:00
committed by Mikhail Larchanka
parent 91ea6fccd3
commit 2eb08f068c
6 changed files with 863 additions and 839 deletions

View File

@@ -50,7 +50,8 @@ AI-Agent/
│ │ ├── rag-service.ts # Embeddings + SQLite; sqlite-vss KNN when available, else dot-product; memory.semantic.*, node.execute semantic_search
│ │ ├── tool-host.ts # shell, http_get (Playwright), http_search; sandbox; tool.execute / node.execute tool
│ │ ├── cron-manager.ts # node-cron + SQLite schedules; event.cron.* to Logger
│ │ ── dashboard-service.ts # Notion-style web monitoring; reads DBs/logs on demand
│ │ ── dashboard/ # Dashboard UI (static files: index.html, app.js, styles.css)
│ │ └── dashboard-service.ts # Serves Dashboard UI and API for system monitoring
│ └── shared/
│ ├── config.ts # Central config: config.json + env overrides
│ ├── protocol.ts # Zod schemas: Envelope, Response, Error, Event
@@ -104,7 +105,7 @@ AI-Agent/
- **RAG Service**: Ollama embed; SQLite-backed document store; **sqlite-vss** for KNN vector search when extension loads (macOS/Linux x64), else in-DB dot-product; configurable `rag.embeddingDimensions` (768); `memory.semantic.insert`, `memory.semantic.search`; search is session-scoped by default to prevent cross-chat leakage; `node.execute` for `semantic_search` returns snippets for downstream nodes.
- **Tool Host**: Registry of tools (shell, http_get, http_search); sandbox dir from config; `tool.execute` and `node.execute` for type `tool`; shell tool executes commands with sandbox restrictions; browsing supports persistent context (cookies) via `userDataDir`.
- **Cron Manager**: SQLite schedule table; node-cron; `cron.schedule.add/list/remove`; emits `event.cron.started/completed/failed` to Logger.
- **Dashboard Service**: Standalone internal monitoring web dashboard; provides real-time overview of tasks, system stats, and event logs via a Notion-inspired UI.
- **Dashboard Service**: Standalone monitoring web dashboard; provides real-time overview of tasks, processes, and IPC logs. UI is served from static files in `src/services/dashboard/`.
### Adapters

View File

@@ -7,6 +7,11 @@
## Done
### AO-14 Refactor Dashboard Service (Split UI/Logic)
- Status: Completed
- Commit: (current)
- Date: 2026-02-23
### AO-13 Final Verification Walkthrough
- Status: Completed
- Commit: dbe54c2

View File

@@ -12,388 +12,6 @@ const ROOT_DIR = path.join(__dirname, '..', '..');
const PORT = parseInt(process.env.DASHBOARD_PORT || '3001', 10);
const CSS = `
:root {
--bg: #ffffff;
--text: #37352f;
--text-muted: #787774;
--subtle: #f7f6f3;
--border: rgba(55, 53, 47, 0.09);
--primary: #2383e2;
--success: #0b6e4f;
--error: #df2a5f;
--warning: #d9730d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #191919;
--text: #d4d4d4;
--text-muted: #8b8b8b;
--subtle: #252525;
--border: rgba(255, 255, 255, 0.09);
--primary: #2ea7ff;
--success: #529e72;
--error: #ff4d4d;
--warning: #ffdc4d;
}
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
background-color: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
line-height: 1.5;
}
.container {
max-width: 900px;
width: 100%;
padding: 80px 40px;
}
header {
margin-bottom: 40px;
}
h1 {
font-size: 40px;
font-weight: 700;
margin: 0 0 10px 0;
display: flex;
align-items: center;
gap: 12px;
}
.live-indicator {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
background: var(--subtle);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--success);
display: flex;
align-items: center;
gap: 6px;
}
.live-indicator::before {
content: '';
width: 6px;
height: 6px;
background: var(--success);
border-radius: 50%;
display: block;
}
.description {
color: var(--text-muted);
font-size: 16px;
margin-bottom: 40px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
background: var(--bg);
}
.card h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric-value {
font-size: 32px;
font-weight: 600;
}
.chart-section {
margin-bottom: 60px;
}
.chart-section h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.models-section {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.model-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
background: var(--subtle);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
margin-right: 12px;
margin-bottom: 12px;
}
.model-pill b { color: var(--primary); }
.chart-container {
height: 240px;
display: flex;
align-items: center;
justify-content: center;
background: var(--subtle);
border-radius: 8px;
padding: 20px;
}
.logs-section h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th {
text-align: left;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
padding: 12px 10px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 12px 10px;
border-bottom: 1px solid var(--border);
font-size: 14px;
vertical-align: middle;
}
.tag {
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 500;
}
.tag.success { background: rgba(11, 110, 79, 0.1); color: var(--success); }
.tag.error { background: rgba(223, 42, 95, 0.1); color: var(--error); }
.tag.warning { background: rgba(217, 115, 13, 0.1); color: var(--warning); }
.tag.running {
background: rgba(35, 131, 226, 0.1);
color: var(--primary);
display: inline-flex;
align-items: center;
gap: 4px;
}
.tag.complexity-small { background: var(--subtle); color: var(--text-muted); border: 1px solid var(--border); }
.tag.complexity-medium { background: rgba(35, 131, 226, 0.1); color: var(--primary); }
.tag.complexity-large { background: rgba(147, 51, 234, 0.1); color: #9333ea; }
.pulse {
width: 6px;
height: 6px;
background: var(--primary);
border-radius: 50%;
animation: pulse-ring 1.5s infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.7); opacity: 1; }
50% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(0.7); opacity: 1; }
}
.node-map {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
align-items: center;
}
.node-stage {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 4px;
border-radius: 6px;
border: 1px dashed var(--border);
background: rgba(0,0,0,0.02);
}
.node-chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.node-chip.running {
border-color: var(--primary);
color: var(--primary);
background: rgba(35, 131, 226, 0.05);
font-weight: 600;
}
.node-chip.completed {
border-color: var(--success);
color: var(--success);
background: rgba(11, 110, 79, 0.05);
}
.node-chip.failed {
border-color: var(--error);
color: var(--error);
background: rgba(223, 42, 95, 0.05);
}
.goal-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
line-height: 1.4;
max-height: 2.8em;
}
.btn-refresh {
font-size: 14px;
font-weight: 500;
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text);
transition: background 0.2s;
}
tr.log-row {
cursor: pointer;
transition: background 0.1s;
}
tr.log-row:hover {
background-color: var(--subtle);
}
.log-details-row {
display: none;
}
.log-details-row.open {
display: table-row;
}
.log-details-container {
padding: 0 20px 20px 20px;
}
.log-details-content {
font-size: 11px;
color: var(--text-muted);
background: var(--subtle);
padding: 12px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid var(--border);
font-family: monospace;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.charts-grid {
grid-template-columns: 1fr;
}
.container {
padding: 40px 20px;
}
}
/* Tooltip bubble using data-title */
[data-title] {
position: relative;
}
[data-title]:hover::after {
content: attr(data-title);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: #1e1e1e;
color: #fff;
font-size: 11px;
font-weight: 500;
border-radius: 6px;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
pointer-events: none;
}
[data-title]:hover::before {
content: '';
position: absolute;
bottom: 115%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: #1e1e1e transparent transparent transparent;
z-index: 1001;
pointer-events: none;
}
`;
export class DashboardService extends BaseProcess {
private readonly server: http.Server;
private processStats: Record<string, any> = {};
@@ -504,13 +122,7 @@ export class DashboardService extends BaseProcess {
return;
}
if (url.pathname === '/') {
res.setHeader('Content-Type', 'text/html');
res.end(this.getHTML());
return;
}
if (url.pathname.startsWith('/api/fail-task')) {
if (url.pathname === '/api/fail-task' || url.pathname.startsWith('/api/fail-task')) {
const taskId = url.searchParams.get('id');
if (taskId) {
try {
@@ -528,6 +140,37 @@ export class DashboardService extends BaseProcess {
}
}
if (url.pathname === '/styles.css') {
const cssPath = path.join(ROOT_DIR, 'src/services/dashboard/styles.css');
if (fs.existsSync(cssPath)) {
res.setHeader('Content-Type', 'text/css');
res.end(fs.readFileSync(cssPath, 'utf8'));
return;
}
}
if (url.pathname === '/app.js') {
const jsPath = path.join(ROOT_DIR, 'src/services/dashboard/app.js');
if (fs.existsSync(jsPath)) {
res.setHeader('Content-Type', 'application/javascript');
res.end(fs.readFileSync(jsPath, 'utf8'));
return;
}
}
if (url.pathname === '/') {
const htmlPath = path.join(ROOT_DIR, 'src/services/dashboard/index.html');
if (fs.existsSync(htmlPath)) {
res.setHeader('Content-Type', 'text/html');
res.end(fs.readFileSync(htmlPath, 'utf8'));
return;
} else {
res.statusCode = 404;
res.end('Dashboard UI files missing.');
return;
}
}
res.statusCode = 404;
res.end('Not Found');
}
@@ -640,454 +283,6 @@ export class DashboardService extends BaseProcess {
}).join('');
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">${bars}</svg>`;
}
private getHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ManBot Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>${CSS}</style>
</head>
<body>
<div class="container">
<header>
<h1>🍱 ManBot Dashboard <span class="live-indicator">LIVE</span></h1>
<p class="description">Real-time internal monitoring for AI-Agent orchestration, memory, and events.</p>
<div style="display: flex; gap: 10px;">
<button class="btn-refresh" onclick="updateDashboard()">Refresh Data</button>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Total Tasks</h2>
<div class="metric-value" id="task-total">0</div>
</div>
<div class="card">
<h2>Knowledge Base</h2>
<div class="metric-value"><span id="rag-count">0</span> <small style="font-size: 14px; font-weight: 400; color: var(--text-muted);">docs</small></div>
</div>
<div class="card">
<h2>Active Schedules</h2>
<div class="metric-value" id="cron-count">0</div>
</div>
<div class="card">
<h2>Peak Nodes</h2>
<div class="metric-value" id="max-nodes">0</div>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(3, 1fr); margin-bottom: 60px;">
<div class="card">
<h2>First Task</h2>
<div class="metric-value" id="time-first" style="font-size: 20px;">-</div>
</div>
<div class="card">
<h2>Last Active</h2>
<div class="metric-value" id="time-last" style="font-size: 20px;">-</div>
</div>
<div class="card">
<h2>Avg Duration</h2>
<div class="metric-value" id="time-avg" style="font-size: 20px;">-</div>
</div>
</div>
<div class="processes-section" style="margin-bottom: 60px;">
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid var(--border);">Processes</h3>
<div id="processes-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
</div>
</div>
<div class="chart-section">
<h3>Analytics</h3>
<div class="charts-grid">
<div class="card">
<h2>Task Distribution</h2>
<div id="c1" class="chart-container"></div>
</div>
<div class="card">
<h2>Task Complexity</h2>
<div id="c2" class="chart-container"></div>
</div>
</div>
<div class="models-section" id="model-mapping" style="border-top: none; margin-top: 20px;">
<div id="model-list"></div>
</div>
</div>
<div class="logs-section" id="ipc-stream-section" style="margin-bottom: 60px;">
<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 20px; padding-bottom: 8px;">
<h3 style="margin: 0; flex: 1;">Live Internal Traffic (IPC)</h3>
<span class="tag success" style="display: inline-flex; align-items: center; gap: 6px;"><div class="pulse" style="margin:0;"></div> Live</span>
</div>
<div class="card" style="padding: 0;">
<table id="ipc-table" style="table-layout: fixed;">
<thead>
<tr>
<th style="padding-left: 20px; width: 15%;">TIME</th>
<th style="width: 25%;">ROUTING</th>
<th style="width: 25%;">TYPE</th>
<th style="padding-right: 20px; width: 35%;">PAYLOAD</th>
</tr>
</thead>
<tbody id="ipc-table-body"></tbody>
</table>
</div>
</div>
<div class="logs-section" id="active-queue-section" style="margin-bottom: 60px; display: none;">
<h3>Active Tasks Queue</h3>
<div class="card" style="padding: 0;">
<table id="queue-table">
<thead>
<tr>
<th style="padding-left: 20px; width: 100px;">UPDATED</th>
<th style="width: 100px;">STATUS</th>
<th style="width: 120px;">COMPLEXITY</th>
<th>GOAL</th>
<th style="padding-right: 20px; text-align: right; width: 80px;">ACTION</th>
</tr>
</thead>
<tbody id="qt"></tbody>
</table>
</div>
</div>
<div class="logs-section">
<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 20px; padding-bottom: 8px;">
<h3 style="margin: 0; flex: 1;">Intelligence Pipeline</h3>
<select id="log-date-select" onchange="updateDashboard()" style="width: 140px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--subtle); color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; height: 32px;">
</select>
</div>
<div class="card" style="padding: 0;">
<table id="log-table" style="table-layout: fixed;">
<thead>
<tr>
<th style="padding-left: 20px; width: 25%;">TIME</th>
<th style="width: 25%;">TYPE</th>
<th style="padding-right: 20px; width: 50%;">CONTENT</th>
</tr>
</thead>
<tbody id="lt"></tbody>
</table>
</div>
<div style="text-align: center; margin-top: 20px;">
<button id="show-more-btn" class="btn-refresh" style="display: none;" onclick="toggleLogs()">Show More</button>
</div>
</div>
</div>
<script>
let allLogs = [];
let showingAll = false;
let lastDashboardState = {};
let lastIpcLogTimestamp = 0;
function updateIpcLogs() {
fetch(\`/api/ipc-logs?since=\${lastIpcLogTimestamp}\`)
.then(r => r.json())
.then(logs => {
if (!logs || !logs.length) return;
lastIpcLogTimestamp = Math.max(...logs.map(l => l.message.timestamp));
const tbody = document.getElementById("ipc-table-body");
if (!tbody) return;
logs.forEach(log => {
const tr = document.createElement("tr");
const ts = new Date(log.message.timestamp);
const tsStr = ts.getHours().toString().padStart(2, '0') + ':' + ts.getMinutes().toString().padStart(2, '0') + ':' + ts.getSeconds().toString().padStart(2, '0') + '.' + ts.getMilliseconds().toString().padStart(3, '0');
let color = "var(--text)";
if (log.message.type && log.message.type.includes("error")) color = "var(--error)";
else if (log.message.type && log.message.type.includes("heartbeat")) color = "var(--text-muted)";
else if (log.message.type && log.message.type.includes("event")) color = "var(--warning)";
else if (log.message.type && log.message.type.includes("response")) color = "var(--success)";
let routeHtml = '';
if (log.direction === '←') {
routeHtml = \`<span style="color:var(--text-muted)">\${log.fromProcess}</span> <strong style="color:var(--primary)">→</strong> <span style="color:var(--text)">\${log.toProcess}</span>\`;
} else {
routeHtml = \`<span style="color:var(--text)">\${log.fromProcess}</span> <strong style="color:var(--primary)">→</strong> <span style="color:var(--text-muted)">\${log.toProcess}</span>\`;
}
tr.innerHTML = \`
<td style="color: var(--text-muted); font-size: 11px; font-family: monospace; padding-left: 20px;">\${tsStr}</td>
<td style="font-size: 11px; font-family: monospace;">\${routeHtml}</td>
<td style="color: \${color}; font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="\${log.message.type}">\${log.message.type}</td>
<td style="font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 20px;" title='\${JSON.stringify(log.message.payload || {})}'>\${JSON.stringify(log.message.payload || {})}</td>
\`;
tbody.insertBefore(tr, tbody.firstChild);
});
while (tbody.children.length > 200) tbody.removeChild(tbody.lastChild);
}).catch(e => console.error(e));
}
setInterval(updateIpcLogs, 1000);
updateIpcLogs();
function getGraphLayout(nodes, edges) {
if (!nodes || nodes.length === 0) return [];
const nodeMap = {};
nodes.forEach(n => nodeMap[n.id] = { ...n, incoming: 0, outgoing: [], level: 0 });
(edges || []).forEach(e => {
if (nodeMap[e.from] && nodeMap[e.to]) {
nodeMap[e.from].outgoing.push(e.to);
nodeMap[e.to].incoming++;
}
});
// Assign levels
let changed = true;
let iterations = 0;
while (changed && iterations < 100) {
changed = false;
iterations++;
nodes.forEach(n => {
const current = nodeMap[n.id];
current.outgoing.forEach(nextId => {
const next = nodeMap[nextId];
if (next.level <= current.level) {
next.level = current.level + 1;
changed = true;
}
});
});
}
const levels = [];
Object.values(nodeMap).forEach(n => {
if (!levels[n.level]) levels[n.level] = [];
levels[n.level].push(n);
});
return levels.filter(l => l && l.length > 0);
}
function fmtDate(d) {
const date = new Date(d);
return \`\${date.getDate()} \${date.toLocaleString('en-US', { month: 'short' })}, \${date.getFullYear()} \${date.toLocaleTimeString('en-GB')}\`;
}
function failTask(id) {
if (!confirm("Mark this task as failed?")) return;
fetch(\`/api/fail-task?id=\${id}\`)
.then(r => r.json())
.then(d => {
if (d.status === 'success') {
updateDashboard();
} else {
alert("Error: " + d.message);
}
});
}
function toggleLogs() {
showingAll = !showingAll;
const btn = document.getElementById("show-more-btn");
btn.textContent = showingAll ? "Show Less" : "Show More";
renderLogs();
}
function renderLogs() {
const lt = document.getElementById("lt");
const logsToRender = showingAll ? allLogs : allLogs.slice(0, 20);
// Check if logs actually changed before rendering
const currentLogsHash = JSON.stringify(logsToRender);
if (lastDashboardState.logsHash === currentLogsHash) return;
lastDashboardState.logsHash = currentLogsHash;
lt.innerHTML = logsToRender.map(l => {
const tc = l.type?.includes("failed") ? "error" : (l.type?.includes("completed") ? "success" : "warning");
const typeLabel = (l.type || "EVENT").split(".").pop();
const mainContent = l.payload?.toolName || l.payload?.nodeId || l.message || "-";
const args = l.payload ? JSON.stringify(l.payload, null, 2) : "";
return \`<tr class="log-row" onclick="const next = this.nextElementSibling; if(next) next.classList.toggle('open')">
<td style="padding-left: 20px; color: var(--text-muted); vertical-align: top; padding-top: 12px;">\${fmtDate(l.time || l.timestamp || Date.now())}</td>
<td style="vertical-align: top; padding-top: 12px;"><span class="tag \${tc}">\${typeLabel}</span></td>
<td style="padding-right: 20px; color: var(--text-muted); font-size: 13px; vertical-align: top; padding-top: 12px;">
<div style="font-weight: 600; color: var(--text);">\${mainContent}</div>
</td>
</tr>
\${args ? \`<tr class="log-details-row">
<td colspan="3">
<div class="log-details-container">
<div class="log-details-content">\${args}</div>
</div>
</td>
</tr>\` : ""}\`;
}).join("");
document.getElementById("show-more-btn").style.display = allLogs.length > 20 ? "inline-block" : "none";
}
function updateDashboard() {
const dateSelect = document.getElementById("log-date-select");
const date = dateSelect.value;
fetch(\`/api/stats\${date ? '?date=' + date : ''}\`)
.then(r => r.json())
.then(d => {
// 1. Update Stats
const statsChanged =
lastDashboardState.rag !== d.rag ||
lastDashboardState.cron !== d.cron ||
lastDashboardState.maxNodes !== d.maxNodes ||
JSON.stringify(lastDashboardState.tasks) !== JSON.stringify(d.tasks) ||
JSON.stringify(lastDashboardState.timing) !== JSON.stringify(d.timing);
if (statsChanged) {
const total = Object.values(d.tasks).reduce((a, b) => a + b, 0);
document.getElementById("task-total").textContent = total;
document.getElementById("rag-count").textContent = d.rag;
document.getElementById("cron-count").textContent = d.cron;
document.getElementById("max-nodes").textContent = d.maxNodes || 0;
document.getElementById("time-first").textContent = d.timing.first;
document.getElementById("time-last").textContent = d.timing.last;
document.getElementById("time-avg").textContent = d.timing.avg;
lastDashboardState.rag = d.rag;
lastDashboardState.cron = d.cron;
lastDashboardState.maxNodes = d.maxNodes;
lastDashboardState.tasks = d.tasks;
lastDashboardState.timing = d.timing;
}
// 1.5 Update Processes
const procsHash = JSON.stringify(d.processes);
if (lastDashboardState.procsHash !== procsHash) {
const pg = document.getElementById("processes-grid");
if (pg && d.processes) {
const html = Object.keys(d.processes).sort().map(pName => {
const p = d.processes[pName];
const isDown = (Date.now() - (p.lastHeartbeat || 0)) > 30000;
const status = isDown ? "OFFLINE" : (p.status || "UNKNOWN").toUpperCase();
const tc = status === "OFFLINE" ? "error" : (status === "READY" ? "success" : "warning");
const indicator = status === "READY" ? '<div class="pulse"></div>' : '';
const restartLabel = p.restartCount ? \`<div style="font-size: 11px; color: var(--error); margin-top: 5px; font-weight: 500;">Restarts: \${p.restartCount}</div>\` : '';
const mem = p.memory && p.memory.rss ? \`<div style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">Mem: \${Math.round(p.memory.rss / 1024 / 1024)}MB</div>\` : '';
const up = p.uptime ? \`<div style="font-size: 11px; color: var(--text-muted);">Uptime: \${p.uptime}s</div>\` : '';
return \`<div class="card" style="padding: 15px;">
<h2 style="margin-bottom: 10px;">\${pName}</h2>
<div style="display: flex; gap: 8px; flex-direction: column;">
<div><span class="tag \${tc}">\${indicator}\${status}</span></div>
<div>\${mem}\${up}</div>
</div>
\${restartLabel}
</div>\`;
}).join("");
pg.innerHTML = html;
}
lastDashboardState.procsHash = procsHash;
}
// 2. Update Models
const modelsHash = JSON.stringify(d.models);
if (lastDashboardState.modelsHash !== modelsHash) {
const modelsSection = document.getElementById("model-list");
modelsSection.innerHTML = Object.entries(d.models)
.filter(([k]) => ['small', 'medium', 'large'].includes(k))
.map(([k, v]) => \`<div class="model-pill"><span>\${k.toUpperCase()}:</span><b>\${v}</b></div>\`)
.join("");
lastDashboardState.modelsHash = modelsHash;
}
// 3. Update Charts
const chartsHash = JSON.stringify(d.charts);
if (lastDashboardState.chartsHash !== chartsHash) {
document.getElementById("c1").innerHTML = d.charts.taskDonut;
document.getElementById("c2").innerHTML = d.charts.compBar;
lastDashboardState.chartsHash = chartsHash;
}
// 4. Update Active Queue
const queueHash = JSON.stringify(d.pendingTasks);
if (lastDashboardState.queueHash !== queueHash) {
const qt = document.getElementById("qt");
const qs = document.getElementById("active-queue-section");
if (d.pendingTasks && d.pendingTasks.length > 0) {
qs.style.display = "block";
qt.innerHTML = d.pendingTasks.map(t => {
const isRunning = t.status === 'running';
const tc = isRunning ? "running" : "warning";
const indicator = isRunning ? '<div class="pulse"></div>' : '';
return \`<tr>
<td style="padding-left: 20px; color: var(--text-muted); white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;">\${fmtDate(t.updated_at)}</td>
<td style="white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;"><span class="tag \${tc}">\${indicator}\${t.status.toUpperCase()}</span></td>
<td style="white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;"><span class="tag complexity-\${t.complexity || 'unknown'}">\${(t.complexity || 'unknown').toUpperCase()}</span></td>
<td style="padding-top: 15px; border-bottom: none;">
<div class="goal-text" data-title="\${t.goal.replace(/"/g, '&quot;')}">\${t.goal}</div>
</td>
<td style="padding-right: 20px; text-align: right; vertical-align: top; padding-top: 15px; border-bottom: none;">
<button onclick="failTask('\${t.id}')" style="cursor: pointer; font-size: 11px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--error);">FAIL</button>
</td>
</tr>
<tr>
<td colspan="5" style="padding-left: 20px; padding-right: 20px; padding-bottom: 20px; border-top: none;">
<div class="node-map">
\${(() => {
const levels = getGraphLayout(t.nodes || [], t.edges || []);
return levels.map(level => \`
<div class="node-stage">
\${level.map(n => {
const activeIndicator = n.status === 'running' ? '<div class="pulse"></div>' : '';
const typeLabel = n.type.split('.').pop().toUpperCase();
let chipAttr = '';
if (n.type === 'skill' && n.input) {
try {
const input = typeof n.input === 'string' ? JSON.parse(n.input) : n.input;
const skillName = input.skillName || input.skill;
if (skillName) chipAttr = \` data-title="Skill: \${skillName}"\`;
} catch(e) {}
}
return \`<div class="node-chip \${n.status}"\${chipAttr}>\${activeIndicator}\${typeLabel}</div>\`;
}).join('')}
</div>
\`).join('<span style="color: var(--border); font-size: 14px; font-weight: 700;">➔</span>');
})()}
</div>
</td>
</tr>\`;
}).join("");
} else {
qs.style.display = "none";
}
lastDashboardState.queueHash = queueHash;
}
// 5. Update Logs
allLogs = d.logs;
renderLogs();
});
}
// Initial load
fetch("/api/log-files")
.then(r => r.json())
.then(files => {
const dateSelect = document.getElementById("log-date-select");
const today = new Date().toISOString().split('T')[0];
if (!files.includes(today)) {
files.unshift(today);
}
dateSelect.innerHTML = files.map(f => \`<option value="\${f}" \${f === today ? 'selected' : ''}>\${f}</option>\`).join("");
updateDashboard();
setInterval(updateDashboard, 5000);
});
</script>
</body>
</html>`;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {

View File

@@ -0,0 +1,299 @@
let allLogs = [];
let showingAll = false;
let lastDashboardState = {};
let lastIpcLogTimestamp = 0;
function updateIpcLogs() {
fetch(`/api/ipc-logs?since=${lastIpcLogTimestamp}`)
.then(r => r.json())
.then(logs => {
if (!logs || !logs.length) return;
lastIpcLogTimestamp = Math.max(...logs.map(l => l.message.timestamp));
const tbody = document.getElementById("ipc-table-body");
if (!tbody) return;
logs.forEach(log => {
const tr = document.createElement("tr");
const ts = new Date(log.message.timestamp);
const tsStr = ts.getHours().toString().padStart(2, '0') + ':' + ts.getMinutes().toString().padStart(2, '0') + ':' + ts.getSeconds().toString().padStart(2, '0') + '.' + ts.getMilliseconds().toString().padStart(3, '0');
let color = "var(--text)";
if (log.message.type && log.message.type.includes("error")) color = "var(--error)";
else if (log.message.type && log.message.type.includes("heartbeat")) color = "var(--text-muted)";
else if (log.message.type && log.message.type.includes("event")) color = "var(--warning)";
else if (log.message.type && log.message.type.includes("response")) color = "var(--success)";
let routeHtml = '';
if (log.direction === '←') {
routeHtml = `<span style="color:var(--text-muted)">${log.fromProcess}</span> <strong style="color:var(--primary)">→</strong> <span style="color:var(--text)">${log.toProcess}</span>`;
} else {
routeHtml = `<span style="color:var(--text)">${log.fromProcess}</span> <strong style="color:var(--primary)">→</strong> <span style="color:var(--text-muted)">${log.toProcess}</span>`;
}
tr.innerHTML = `
<td style="color: var(--text-muted); font-size: 11px; font-family: monospace; padding-left: 20px;">${tsStr}</td>
<td style="font-size: 11px; font-family: monospace;">${routeHtml}</td>
<td style="color: ${color}; font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${log.message.type}">${log.message.type}</td>
<td style="font-size: 11px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 20px;" title='${JSON.stringify(log.message.payload || {})}'>${JSON.stringify(log.message.payload || {})}</td>
`;
tbody.insertBefore(tr, tbody.firstChild);
});
while (tbody.children.length > 200) tbody.removeChild(tbody.lastChild);
}).catch(e => console.error(e));
}
setInterval(updateIpcLogs, 1000);
updateIpcLogs();
function getGraphLayout(nodes, edges) {
if (!nodes || nodes.length === 0) return [];
const nodeMap = {};
nodes.forEach(n => nodeMap[n.id] = { ...n, incoming: 0, outgoing: [], level: 0 });
(edges || []).forEach(e => {
if (nodeMap[e.from] && nodeMap[e.to]) {
nodeMap[e.from].outgoing.push(e.to);
nodeMap[e.to].incoming++;
}
});
// Assign levels
let changed = true;
let iterations = 0;
while (changed && iterations < 100) {
changed = false;
iterations++;
nodes.forEach(n => {
const current = nodeMap[n.id];
current.outgoing.forEach(nextId => {
const next = nodeMap[nextId];
if (next.level <= current.level) {
next.level = current.level + 1;
changed = true;
}
});
});
}
const levels = [];
Object.values(nodeMap).forEach(n => {
if (!levels[n.level]) levels[n.level] = [];
levels[n.level].push(n);
});
return levels.filter(l => l && l.length > 0);
}
function fmtDate(d) {
const date = new Date(d);
return `${date.getDate()} ${date.toLocaleString('en-US', { month: 'short' })}, ${date.getFullYear()} ${date.toLocaleTimeString('en-GB')}`;
}
function failTask(id) {
if (!confirm("Mark this task as failed?")) return;
fetch(`/api/fail-task?id=${id}`)
.then(r => r.json())
.then(d => {
if (d.status === 'success') {
updateDashboard();
} else {
alert("Error: " + d.message);
}
});
}
function toggleLogs() {
showingAll = !showingAll;
const btn = document.getElementById("show-more-btn");
btn.textContent = showingAll ? "Show Less" : "Show More";
renderLogs();
}
function renderLogs() {
const lt = document.getElementById("lt");
const logsToRender = showingAll ? allLogs : allLogs.slice(0, 20);
// Check if logs actually changed before rendering
const currentLogsHash = JSON.stringify(logsToRender);
if (lastDashboardState.logsHash === currentLogsHash) return;
lastDashboardState.logsHash = currentLogsHash;
lt.innerHTML = logsToRender.map(l => {
const tc = l.type?.includes("failed") ? "error" : (l.type?.includes("completed") ? "success" : "warning");
const typeLabel = (l.type || "EVENT").split(".").pop();
const mainContent = l.payload?.toolName || l.payload?.nodeId || l.message || "-";
const args = l.payload ? JSON.stringify(l.payload, null, 2) : "";
return `<tr class="log-row" onclick="const next = this.nextElementSibling; if(next) next.classList.toggle('open')">
<td style="padding-left: 20px; color: var(--text-muted); vertical-align: top; padding-top: 12px;">${fmtDate(l.time || l.timestamp || Date.now())}</td>
<td style="vertical-align: top; padding-top: 12px;"><span class="tag ${tc}">${typeLabel}</span></td>
<td style="padding-right: 20px; color: var(--text-muted); font-size: 13px; vertical-align: top; padding-top: 12px;">
<div style="font-weight: 600; color: var(--text);">${mainContent}</div>
</td>
</tr>
${args ? `<tr class="log-details-row">
<td colspan="3">
<div class="log-details-container">
<div class="log-details-content">${args}</div>
</div>
</td>
</tr>` : ""}`;
}).join("");
document.getElementById("show-more-btn").style.display = allLogs.length > 20 ? "inline-block" : "none";
}
function updateDashboard() {
const dateSelect = document.getElementById("log-date-select");
const date = dateSelect.value;
fetch(`/api/stats${date ? '?date=' + date : ''}`)
.then(r => r.json())
.then(d => {
// 1. Update Stats
const statsChanged =
lastDashboardState.rag !== d.rag ||
lastDashboardState.cron !== d.cron ||
lastDashboardState.maxNodes !== d.maxNodes ||
JSON.stringify(lastDashboardState.tasks) !== JSON.stringify(d.tasks) ||
JSON.stringify(lastDashboardState.timing) !== JSON.stringify(d.timing);
if (statsChanged) {
const total = Object.values(d.tasks).reduce((a, b) => a + b, 0);
document.getElementById("task-total").textContent = total;
document.getElementById("rag-count").textContent = d.rag;
document.getElementById("cron-count").textContent = d.cron;
document.getElementById("max-nodes").textContent = d.maxNodes || 0;
document.getElementById("time-first").textContent = d.timing.first;
document.getElementById("time-last").textContent = d.timing.last;
document.getElementById("time-avg").textContent = d.timing.avg;
lastDashboardState.rag = d.rag;
lastDashboardState.cron = d.cron;
lastDashboardState.maxNodes = d.maxNodes;
lastDashboardState.tasks = d.tasks;
lastDashboardState.timing = d.timing;
}
// 1.5 Update Processes
const procsHash = JSON.stringify(d.processes);
if (lastDashboardState.procsHash !== procsHash) {
const pg = document.getElementById("processes-grid");
if (pg && d.processes) {
const html = Object.keys(d.processes).sort().map(pName => {
const p = d.processes[pName];
const isDown = (Date.now() - (p.lastHeartbeat || 0)) > 30000;
const status = isDown ? "OFFLINE" : (p.status || "UNKNOWN").toUpperCase();
const tc = status === "OFFLINE" ? "error" : (status === "READY" ? "success" : "warning");
const indicator = status === "READY" ? '<div class="pulse"></div>' : '';
const restartLabel = p.restartCount ? `<div style="font-size: 11px; color: var(--error); margin-top: 5px; font-weight: 500;">Restarts: ${p.restartCount}</div>` : '';
const mem = p.memory && p.memory.rss ? `<div style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">Mem: ${Math.round(p.memory.rss / 1024 / 1024)}MB</div>` : '';
const up = p.uptime ? `<div style="font-size: 11px; color: var(--text-muted);">Uptime: ${p.uptime}s</div>` : '';
return `<div class="card" style="padding: 15px;">
<h2 style="margin-bottom: 10px;">${pName}</h2>
<div style="display: flex; gap: 8px; flex-direction: column;">
<div><span class="tag ${tc}">${indicator}${status}</span></div>
<div>${mem}${up}</div>
</div>
${restartLabel}
</div>`;
}).join("");
pg.innerHTML = html;
}
lastDashboardState.procsHash = procsHash;
}
// 2. Update Models
const modelsHash = JSON.stringify(d.models);
if (lastDashboardState.modelsHash !== modelsHash) {
const modelsSection = document.getElementById("model-list");
modelsSection.innerHTML = Object.entries(d.models)
.filter(([k]) => ['small', 'medium', 'large'].includes(k))
.map(([k, v]) => `<div class="model-pill"><span>${k.toUpperCase()}:</span><b>${v}</b></div>`)
.join("");
lastDashboardState.modelsHash = modelsHash;
}
// 3. Update Charts
const chartsHash = JSON.stringify(d.charts);
if (lastDashboardState.chartsHash !== chartsHash) {
document.getElementById("c1").innerHTML = d.charts.taskDonut;
document.getElementById("c2").innerHTML = d.charts.compBar;
lastDashboardState.chartsHash = chartsHash;
}
// 4. Update Active Queue
const queueHash = JSON.stringify(d.pendingTasks);
if (lastDashboardState.queueHash !== queueHash) {
const qt = document.getElementById("qt");
const qs = document.getElementById("active-queue-section");
if (d.pendingTasks && d.pendingTasks.length > 0) {
qs.style.display = "block";
qt.innerHTML = d.pendingTasks.map(t => {
const isRunning = t.status === 'running';
const tc = isRunning ? "running" : "warning";
const indicator = isRunning ? '<div class="pulse"></div>' : '';
return `<tr>
<td style="padding-left: 20px; color: var(--text-muted); white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;">${fmtDate(t.updated_at)}</td>
<td style="white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;"><span class="tag ${tc}">${indicator}${t.status.toUpperCase()}</span></td>
<td style="white-space: nowrap; vertical-align: top; padding-top: 15px; border-bottom: none;"><span class="tag complexity-${t.complexity || 'unknown'}">${(t.complexity || 'unknown').toUpperCase()}</span></td>
<td style="padding-top: 15px; border-bottom: none;">
<div class="goal-text" data-title="${t.goal.replace(/"/g, '&quot;')}">${t.goal}</div>
</td>
<td style="padding-right: 20px; text-align: right; vertical-align: top; padding-top: 15px; border-bottom: none;">
<button onclick="failTask('${t.id}')" style="cursor: pointer; font-size: 11px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--error);">FAIL</button>
</td>
</tr>
<tr>
<td colspan="5" style="padding-left: 20px; padding-right: 20px; padding-bottom: 20px; border-top: none;">
<div class="node-map">
${(() => {
const levels = getGraphLayout(t.nodes || [], t.edges || []);
return levels.map(level => `
<div class="node-stage">
${level.map(n => {
const activeIndicator = n.status === 'running' ? '<div class="pulse"></div>' : '';
const typeLabel = n.type.split('.').pop().toUpperCase();
let chipAttr = '';
if (n.type === 'skill' && n.input) {
try {
const input = typeof n.input === 'string' ? JSON.parse(n.input) : n.input;
const skillName = input.skillName || input.skill;
if (skillName) chipAttr = ` data-title="Skill: ${skillName}"`;
} catch(e) {}
}
return `<div class="node-chip ${n.status}"${chipAttr}>${activeIndicator}${typeLabel}</div>`;
}).join('')}
</div>
`).join('<span style="color: var(--border); font-size: 14px; font-weight: 700;">➔</span>');
})()}
</div>
</td>
</tr>`;
}).join("");
} else {
qs.style.display = "none";
}
lastDashboardState.queueHash = queueHash;
}
// 5. Update Logs
allLogs = d.logs;
renderLogs();
});
}
// Initial load
fetch("/api/log-files")
.then(r => r.json())
.then(files => {
const dateSelect = document.getElementById("log-date-select");
const today = new Date().toISOString().split('T')[0];
if (!files.includes(today)) {
files.unshift(today);
}
dateSelect.innerHTML = files.map(f => `<option value="${f}" ${f === today ? 'selected' : ''}>${f}</option>`).join("");
updateDashboard();
setInterval(updateDashboard, 5000);
});

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ManBot Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🍱 ManBot Dashboard <span class="live-indicator">LIVE</span></h1>
<p class="description">Real-time internal monitoring for AI-Agent orchestration, memory, and events.</p>
<div style="display: flex; gap: 10px;">
<button class="btn-refresh" onclick="updateDashboard()">Refresh Data</button>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Total Tasks</h2>
<div class="metric-value" id="task-total">0</div>
</div>
<div class="card">
<h2>Knowledge Base</h2>
<div class="metric-value"><span id="rag-count">0</span> <small style="font-size: 14px; font-weight: 400; color: var(--text-muted);">docs</small></div>
</div>
<div class="card">
<h2>Active Schedules</h2>
<div class="metric-value" id="cron-count">0</div>
</div>
<div class="card">
<h2>Peak Nodes</h2>
<div class="metric-value" id="max-nodes">0</div>
</div>
</div>
<div class="grid" style="grid-template-columns: repeat(3, 1fr); margin-bottom: 60px;">
<div class="card">
<h2>First Task</h2>
<div class="metric-value" id="time-first" style="font-size: 20px;">-</div>
</div>
<div class="card">
<h2>Last Active</h2>
<div class="metric-value" id="time-last" style="font-size: 20px;">-</div>
</div>
<div class="card">
<h2>Avg Duration</h2>
<div class="metric-value" id="time-avg" style="font-size: 20px;">-</div>
</div>
</div>
<div class="processes-section" style="margin-bottom: 60px;">
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 20px; padding-bottom: 8px; border-bottom: 1px solid var(--border);">Processes</h3>
<div id="processes-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px;">
</div>
</div>
<div class="chart-section">
<h3>Analytics</h3>
<div class="charts-grid">
<div class="card">
<h2>Task Distribution</h2>
<div id="c1" class="chart-container"></div>
</div>
<div class="card">
<h2>Task Complexity</h2>
<div id="c2" class="chart-container"></div>
</div>
</div>
<div class="models-section" id="model-mapping" style="border-top: none; margin-top: 20px;">
<div id="model-list"></div>
</div>
</div>
<div class="logs-section" id="ipc-stream-section" style="margin-bottom: 60px;">
<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 20px; padding-bottom: 8px;">
<h3 style="margin: 0; flex: 1;">Live Internal Traffic (IPC)</h3>
<span class="tag success" style="display: inline-flex; align-items: center; gap: 6px;"><div class="pulse" style="margin:0;"></div> Live</span>
</div>
<div class="card" style="padding: 0;">
<table id="ipc-table" style="table-layout: fixed;">
<thead>
<tr>
<th style="padding-left: 20px; width: 15%;">TIME</th>
<th style="width: 25%;">ROUTING</th>
<th style="width: 25%;">TYPE</th>
<th style="padding-right: 20px; width: 35%;">PAYLOAD</th>
</tr>
</thead>
<tbody id="ipc-table-body"></tbody>
</table>
</div>
</div>
<div class="logs-section" id="active-queue-section" style="margin-bottom: 60px; display: none;">
<h3>Active Tasks Queue</h3>
<div class="card" style="padding: 0;">
<table id="queue-table">
<thead>
<tr>
<th style="padding-left: 20px; width: 100px;">UPDATED</th>
<th style="width: 100px;">STATUS</th>
<th style="width: 120px;">COMPLEXITY</th>
<th>GOAL</th>
<th style="padding-right: 20px; text-align: right; width: 80px;">ACTION</th>
</tr>
</thead>
<tbody id="qt"></tbody>
</table>
</div>
</div>
<div class="logs-section">
<div style="display: flex; gap: 0.5rem; align-items: center; margin-bottom: 20px; padding-bottom: 8px;">
<h3 style="margin: 0; flex: 1;">Intelligence Pipeline</h3>
<select id="log-date-select" onchange="updateDashboard()" style="width: 140px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--subtle); color: var(--text); font-family: inherit; font-size: 13px; cursor: pointer; height: 32px;">
</select>
</div>
<div class="card" style="padding: 0;">
<table id="log-table" style="table-layout: fixed;">
<thead>
<tr>
<th style="padding-left: 20px; width: 25%;">TIME</th>
<th style="width: 25%;">TYPE</th>
<th style="padding-right: 20px; width: 50%;">CONTENT</th>
</tr>
</thead>
<tbody id="lt"></tbody>
</table>
</div>
<div style="text-align: center; margin-top: 20px;">
<button id="show-more-btn" class="btn-refresh" style="display: none;" onclick="toggleLogs()">Show More</button>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,379 @@
:root {
--bg: #ffffff;
--text: #37352f;
--text-muted: #787774;
--subtle: #f7f6f3;
--border: rgba(55, 53, 47, 0.09);
--primary: #2383e2;
--success: #0b6e4f;
--error: #df2a5f;
--warning: #d9730d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #191919;
--text: #d4d4d4;
--text-muted: #8b8b8b;
--subtle: #252525;
--border: rgba(255, 255, 255, 0.09);
--primary: #2ea7ff;
--success: #529e72;
--error: #ff4d4d;
--warning: #ffdc4d;
}
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
background-color: var(--bg);
color: var(--text);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
line-height: 1.5;
}
.container {
max-width: 900px;
width: 100%;
padding: 80px 40px;
}
header {
margin-bottom: 40px;
}
h1 {
font-size: 40px;
font-weight: 700;
margin: 0 0 10px 0;
display: flex;
align-items: center;
gap: 12px;
}
.live-indicator {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
background: var(--subtle);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--success);
display: flex;
align-items: center;
gap: 6px;
}
.live-indicator::before {
content: '';
width: 6px;
height: 6px;
background: var(--success);
border-radius: 50%;
display: block;
}
.description {
color: var(--text-muted);
font-size: 16px;
margin-bottom: 40px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 40px;
}
.card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
background: var(--bg);
}
.card h2 {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.metric-value {
font-size: 32px;
font-weight: 600;
}
.chart-section {
margin-bottom: 60px;
}
.chart-section h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 30px;
}
.models-section {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.model-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
background: var(--subtle);
border: 1px solid var(--border);
border-radius: 20px;
font-size: 13px;
margin-right: 12px;
margin-bottom: 12px;
}
.model-pill b { color: var(--primary); }
.chart-container {
height: 240px;
display: flex;
align-items: center;
justify-content: center;
background: var(--subtle);
border-radius: 8px;
padding: 20px;
}
.logs-section h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th {
text-align: left;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
padding: 12px 10px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 12px 10px;
border-bottom: 1px solid var(--border);
font-size: 14px;
vertical-align: middle;
}
.tag {
font-size: 12px;
padding: 2px 6px;
border-radius: 3px;
font-weight: 500;
}
.tag.success { background: rgba(11, 110, 79, 0.1); color: var(--success); }
.tag.error { background: rgba(223, 42, 95, 0.1); color: var(--error); }
.tag.warning { background: rgba(217, 115, 13, 0.1); color: var(--warning); }
.tag.running {
background: rgba(35, 131, 226, 0.1);
color: var(--primary);
display: inline-flex;
align-items: center;
gap: 4px;
}
.tag.complexity-small { background: var(--subtle); color: var(--text-muted); border: 1px solid var(--border); }
.tag.complexity-medium { background: rgba(35, 131, 226, 0.1); color: var(--primary); }
.tag.complexity-large { background: rgba(147, 51, 234, 0.1); color: #9333ea; }
.pulse {
width: 6px;
height: 6px;
background: var(--primary);
border-radius: 50%;
animation: pulse-ring 1.5s infinite;
}
@keyframes pulse-ring {
0% { transform: scale(0.7); opacity: 1; }
50% { transform: scale(1); opacity: 0.5; }
100% { transform: scale(0.7); opacity: 1; }
}
.node-map {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
align-items: center;
}
.node-stage {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 4px;
border-radius: 6px;
border: 1px dashed var(--border);
background: rgba(0,0,0,0.02);
}
.node-chip {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.node-chip.running {
border-color: var(--primary);
color: var(--primary);
background: rgba(35, 131, 226, 0.05);
font-weight: 600;
}
.node-chip.completed {
border-color: var(--success);
color: var(--success);
background: rgba(11, 110, 79, 0.05);
}
.node-chip.failed {
border-color: var(--error);
color: var(--error);
background: rgba(223, 42, 95, 0.05);
}
.goal-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
line-height: 1.4;
max-height: 2.8em;
}
.btn-refresh {
font-size: 14px;
font-weight: 500;
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text);
transition: background 0.2s;
}
tr.log-row {
cursor: pointer;
transition: background 0.1s;
}
tr.log-row:hover {
background-color: var(--subtle);
}
.log-details-row {
display: none;
}
.log-details-row.open {
display: table-row;
}
.log-details-container {
padding: 0 20px 20px 20px;
}
.log-details-content {
font-size: 11px;
color: var(--text-muted);
background: var(--subtle);
padding: 12px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid var(--border);
font-family: monospace;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr 1fr;
}
.charts-grid {
grid-template-columns: 1fr;
}
.container {
padding: 40px 20px;
}
}
/* Tooltip bubble using data-title */
[data-title] {
position: relative;
}
[data-title]:hover::after {
content: attr(data-title);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: #1e1e1e;
color: #fff;
font-size: 11px;
font-weight: 500;
border-radius: 6px;
white-space: nowrap;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
border: 1px solid rgba(255,255,255,0.1);
pointer-events: none;
}
[data-title]:hover::before {
content: '';
position: absolute;
bottom: 115%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: #1e1e1e transparent transparent transparent;
z-index: 1001;
pointer-events: none;
}