mirror of
https://github.com/larchanka/manbot.git
synced 2026-05-13 21:42:08 +00:00
AO-14: Refactor DashboardService - split UI assets into separate files for better maintainability
This commit is contained in:
committed by
Mikhail Larchanka
parent
91ea6fccd3
commit
2eb08f068c
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '"')}">\${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)) {
|
||||
|
||||
299
src/services/dashboard/app.js
Normal file
299
src/services/dashboard/app.js
Normal 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, '"')}">${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);
|
||||
});
|
||||
145
src/services/dashboard/index.html
Normal file
145
src/services/dashboard/index.html
Normal 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>
|
||||
379
src/services/dashboard/styles.css
Normal file
379
src/services/dashboard/styles.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user