mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
feat: Agent Command Center overview with live agents, approvals, usage (fixes #195)
Replace the single-endpoint Overview with a mission-control home that fetches 9 APIs in parallel (Promise.allSettled): 6 live metric cards with sparklines, an active-agents table with a detail drawer, a priority-sorted needs-attention feed with inline approve/deny, a recent-activity timeline, and an omnibar CTA that opens the existing Ctrl+K command palette. Polling pauses on hidden tabs and intervals are torn down on cleanup. All API strings are HTML-escaped. Adds reusable metricCard, sparklineSVG, esc and timeAgo helpers to _helpers.js. Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
1 parent
2ebb56f609
commit
7998b8ccc0
2 files changed
+507
-49
No files matched your search
@@ -57,6 +57,101 @@ export function searchInput(placeholder = 'Search…', id = 'db-search-input') {
|
||||
return `<input id="${id}" type="search" placeholder="${placeholder}" style="width:100%;max-width:320px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box" />`;
|
||||
}
|
||||
|
||||
/** Escape a string for safe interpolation into template literals. */
|
||||
export function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG sparkline (no external library).
|
||||
*
|
||||
* @param {number[]} values - Series values (empty renders a flat baseline).
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.width=96]
|
||||
* @param {number} [opts.height=32]
|
||||
* @param {string} [opts.color] - Stroke colour (defaults to accent token).
|
||||
* @returns {string} SVG markup.
|
||||
*/
|
||||
export function sparklineSVG(values, { width = 96, height = 32, color = 'var(--db-accent,#6366f1)' } = {}) {
|
||||
const data = Array.isArray(values) ? values.filter((v) => typeof v === 'number' && !isNaN(v)) : [];
|
||||
const pad = 2;
|
||||
const w = width - pad * 2;
|
||||
const h = height - pad * 2;
|
||||
if (data.length < 2) {
|
||||
const y = height / 2;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><line x1="${pad}" y1="${y}" x2="${width - pad}" y2="${y}" stroke="var(--db-border,#3f3f46)" stroke-width="1.5" /></svg>`;
|
||||
}
|
||||
const min = Math.min(...data);
|
||||
const max = Math.max(...data);
|
||||
const range = max - min || 1;
|
||||
const pts = data.map((v, i) => {
|
||||
const x = pad + (i / (data.length - 1)) * w;
|
||||
const yy = pad + h - ((v - min) / range) * h;
|
||||
return `${x.toFixed(1)},${yy.toFixed(1)}`;
|
||||
});
|
||||
const areaPts = `${pad},${(pad + h).toFixed(1)} ${pts.join(' ')} ${(pad + w).toFixed(1)},${(pad + h).toFixed(1)}`;
|
||||
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polygon points="${areaPts}" fill="${color}" opacity="0.12" />
|
||||
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric card with value, subtitle, optional delta and sparkline.
|
||||
* Renders a clickable card that navigates via data-nav (handled by caller).
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.title - Card label.
|
||||
* @param {string|number} opts.value - Primary value (already formatted).
|
||||
* @param {string} [opts.subtitle] - Secondary label.
|
||||
* @param {number} [opts.delta] - Signed percentage delta.
|
||||
* @param {number[]} [opts.spark] - Sparkline series.
|
||||
* @param {string} [opts.nav] - Page id to navigate to on click.
|
||||
* @param {string} [opts.accent] - Value colour override.
|
||||
* @returns {string} HTML string.
|
||||
*/
|
||||
export function metricCard({ title, value, subtitle = '', delta, spark, nav, accent }) {
|
||||
let deltaHtml = '';
|
||||
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
|
||||
const up = delta > 0;
|
||||
const col = up ? '#22c55e' : '#ef4444';
|
||||
const arrow = up ? '▲' : '▼';
|
||||
deltaHtml = `<span style="font-size:11px;color:${col};margin-left:6px">${arrow} ${Math.abs(delta).toFixed(0)}%</span>`;
|
||||
}
|
||||
const sparkHtml = Array.isArray(spark) ? `<div style="margin-top:8px">${sparklineSVG(spark)}</div>` : '';
|
||||
const clickable = nav ? `data-nav="${esc(nav)}" role="button" tabindex="0" style="cursor:pointer"` : '';
|
||||
return `<div class="db-card db-metric-card" ${clickable}>
|
||||
<div class="db-card-title">${esc(title)}</div>
|
||||
<div class="db-card-value"${accent ? ` style="color:${accent}"` : ''}>${esc(value)}${deltaHtml}</div>
|
||||
<div class="db-card-footer">${esc(subtitle)}</div>
|
||||
${sparkHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Relative "time ago" label from an ISO string or epoch (ms or s). */
|
||||
export function timeAgo(input) {
|
||||
if (input == null) return '';
|
||||
let ms;
|
||||
if (typeof input === 'number') {
|
||||
ms = input < 1e12 ? input * 1000 : input;
|
||||
} else {
|
||||
const t = Date.parse(input);
|
||||
if (isNaN(t)) return '';
|
||||
ms = t;
|
||||
}
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 0) return 'now';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
/** Modal shell for view-local dialogs. */
|
||||
export function modalShell(id, innerHtml) {
|
||||
return `<div id="${id}" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center;padding:20px">
|
||||
|
||||
@@ -1,59 +1,422 @@
|
||||
/**
|
||||
* Overview View — system stats, uptime, feature health
|
||||
* Renders into [data-page="overview"]
|
||||
* Overview View — Agent Command Center (STITCH-001).
|
||||
*
|
||||
* Mission-control home: live metric cards, agent table, needs-attention feed,
|
||||
* activity timeline, and an omnibar that opens the existing command palette.
|
||||
*
|
||||
* Data sources (parallel, fault-tolerant via Promise.allSettled):
|
||||
* /api/agents, /api/sessions, /api/approvals/pending, /api/usage/summary,
|
||||
* /api/usage/timeseries, /api/channels, /api/gateway/status,
|
||||
* /api/features, /api/traces
|
||||
*
|
||||
* Renders into [data-page="overview"].
|
||||
*/
|
||||
export async function render(container) {
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let features = [], startTime = Date.now();
|
||||
try {
|
||||
const res = await fetch('/api/features');
|
||||
const data = await res.json();
|
||||
features = data.features || [];
|
||||
} catch(e) { /* ignore */ }
|
||||
import { metricCard, esc, timeAgo } from '/plugins/views/_helpers.js';
|
||||
|
||||
const healthy = features.filter(f => f.health?.status === 'ok').length;
|
||||
const total = features.length;
|
||||
const uptimeMs = Date.now() - (window.__aiuiLoadTime || Date.now());
|
||||
const uptime = uptimeMs < 60000 ? `${Math.round(uptimeMs/1000)}s` : `${Math.round(uptimeMs/60000)}m`;
|
||||
const METRICS_POLL_MS = 30000;
|
||||
const APPROVALS_POLL_MS = 10000;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="db-columns" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Status</div>
|
||||
<div class="db-card-value" style="color:#22c55e">● Online</div>
|
||||
<div class="db-card-footer">Uptime: ${uptime}</div>
|
||||
let _metricsTimer = null;
|
||||
let _approvalsTimer = null;
|
||||
let _visibilityHandler = null;
|
||||
let _container = null;
|
||||
|
||||
const STATUS_COLORS = { running: '#22c55e', active: '#22c55e', idle: '#71717a', error: '#ef4444' };
|
||||
|
||||
function navigate(pageId) {
|
||||
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
|
||||
}
|
||||
|
||||
function openPalette() {
|
||||
if (window.aiui?.openCommandPalette) window.aiui.openCommandPalette();
|
||||
else if (window.openCommandPalette) window.openCommandPalette();
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function settled(results, idx, fallback) {
|
||||
const r = results[idx];
|
||||
return r && r.status === 'fulfilled' ? r.value : fallback;
|
||||
}
|
||||
|
||||
function toArray(data, ...keys) {
|
||||
if (Array.isArray(data)) return data;
|
||||
for (const k of keys) {
|
||||
if (data && Array.isArray(data[k])) return data[k];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function statusDot(status) {
|
||||
const s = (status || 'idle').toLowerCase();
|
||||
const color = STATUS_COLORS[s] || STATUS_COLORS.idle;
|
||||
return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color}" title="${esc(s)}"></span>`;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const results = await Promise.allSettled([
|
||||
fetchJson('/api/agents'),
|
||||
fetchJson('/api/sessions'),
|
||||
fetchJson('/api/approvals/pending'),
|
||||
fetchJson('/api/usage/summary'),
|
||||
fetchJson('/api/usage/timeseries?hours=24'),
|
||||
fetchJson('/api/channels'),
|
||||
fetchJson('/api/gateway/status'),
|
||||
fetchJson('/api/features'),
|
||||
fetchJson('/api/traces?limit=10'),
|
||||
]);
|
||||
|
||||
const agents = toArray(settled(results, 0, {}), 'agents');
|
||||
const sessions = toArray(settled(results, 1, {}), 'sessions');
|
||||
const pending = toArray(settled(results, 2, {}), 'approvals', 'pending');
|
||||
const usage = settled(results, 3, null);
|
||||
const timeseries = settled(results, 4, null);
|
||||
const channels = toArray(settled(results, 5, {}), 'channels');
|
||||
const gateway = settled(results, 6, null);
|
||||
const features = toArray(settled(results, 7, {}), 'features');
|
||||
const traces = toArray(settled(results, 8, {}), 'traces');
|
||||
|
||||
return {
|
||||
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces,
|
||||
errors: {
|
||||
agents: results[0].status === 'rejected',
|
||||
sessions: results[1].status === 'rejected',
|
||||
usage: results[3].status === 'rejected',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function usageSpark(timeseries) {
|
||||
const ts = timeseries && Array.isArray(timeseries.timeseries) ? timeseries.timeseries : [];
|
||||
return ts.map((p) => Number(p.tokens) || 0);
|
||||
}
|
||||
|
||||
function todayTokens(usage) {
|
||||
if (!usage) return null;
|
||||
const s = usage.summary || usage;
|
||||
return s.today_tokens ?? s.total_tokens ?? s.tokens ?? null;
|
||||
}
|
||||
|
||||
function gatewayLabel(gateway) {
|
||||
if (!gateway) return { value: 'Standalone', accent: '#71717a' };
|
||||
if (gateway.status === 'unavailable' || gateway.connected === false) {
|
||||
if (gateway.auth_required || gateway.status === 'auth_required') {
|
||||
return { value: 'Auth required', accent: '#eab308' };
|
||||
}
|
||||
return { value: 'Standalone', accent: '#71717a' };
|
||||
}
|
||||
return { value: 'OK', accent: '#22c55e' };
|
||||
}
|
||||
|
||||
function renderMetrics(data) {
|
||||
const { agents, sessions, pending, usage, timeseries, channels, gateway, errors } = data;
|
||||
const channelsOnline = channels.filter((c) => c.running === true || c.connected === true).length;
|
||||
const tokens = todayTokens(usage);
|
||||
const gw = gatewayLabel(gateway);
|
||||
|
||||
const cards = [
|
||||
metricCard({
|
||||
title: 'Active Agents', value: agents.length, subtitle: 'registered', nav: 'agents',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Live Sessions', value: errors.sessions ? '—' : sessions.length, subtitle: 'open', nav: 'sessions',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Pending Approvals', value: pending.length, subtitle: 'awaiting action', nav: 'approvals',
|
||||
accent: pending.length > 0 ? '#eab308' : undefined,
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Tokens Today', value: tokens == null ? '—' : Number(tokens).toLocaleString(),
|
||||
subtitle: 'tokens', nav: 'usage', spark: usageSpark(timeseries),
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Channel Health', value: `${channelsOnline}/${channels.length}`, subtitle: 'channels online', nav: 'channels',
|
||||
}),
|
||||
metricCard({
|
||||
title: 'Gateway Status', value: gw.value, subtitle: gateway?.runtime_type || 'runtime', nav: 'config', accent: gw.accent,
|
||||
}),
|
||||
];
|
||||
|
||||
return `<div class="db-metric-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px">${cards.join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderAgentTable(agents) {
|
||||
if (agents.length === 0) {
|
||||
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">🤖</div>
|
||||
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No agents registered yet</div>
|
||||
<div style="font-size:13px;margin-bottom:1rem">Configure agents to see them here.</div>
|
||||
<button data-nav="agents" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px">Configure agents</button>
|
||||
</div>`;
|
||||
}
|
||||
const rows = agents.slice(0, 20).map((a) => {
|
||||
const id = a.id || a.name || '';
|
||||
const preview = a.instructions || a.description || '';
|
||||
return `<tr data-agent-id="${esc(id)}" class="db-agent-row" style="cursor:pointer;border-top:1px solid var(--db-border)">
|
||||
<td style="padding:10px 12px">${statusDot(a.status)}</td>
|
||||
<td style="padding:10px 12px;font-weight:500">${esc(a.name || id)}</td>
|
||||
<td style="padding:10px 12px"><span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(a.model || '—')}</span></td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px">${esc(timeAgo(a.last_active || a.updated_at) || (a.status || 'idle'))}</td>
|
||||
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview.slice(0, 80))}</td>
|
||||
<td style="padding:10px 12px"><button class="db-agent-chat" data-agent-id="${esc(id)}" style="font-size:11px;padding:4px 12px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:6px;cursor:pointer">Chat</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
return `<div class="db-card" style="padding:0;overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<thead><tr style="text-align:left;color:var(--db-text-dim);font-size:11px;text-transform:uppercase">
|
||||
<th style="padding:10px 12px"></th><th style="padding:10px 12px">Name</th><th style="padding:10px 12px">Model</th>
|
||||
<th style="padding:10px 12px">Last active</th><th style="padding:10px 12px">Preview</th><th style="padding:10px 12px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildAttention(data) {
|
||||
const items = [];
|
||||
data.pending.forEach((a) => {
|
||||
items.push({
|
||||
priority: 0, border: '#ef4444',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Approve: ${esc(a.tool_name || a.tool || a.action || 'request')}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim)">${esc(a.agent_name || a.agent || '')}</div></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="atn-approve" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:pointer">Approve</button>
|
||||
<button class="atn-deny" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer">Deny</button>
|
||||
</div>`,
|
||||
});
|
||||
});
|
||||
data.features.filter((f) => f.health && f.health.status !== 'ok').forEach((f) => {
|
||||
items.push({
|
||||
priority: 1, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Feature degraded: ${esc(f.name || 'unknown')}</div></div>
|
||||
<button data-nav="debug" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">View</button>`,
|
||||
});
|
||||
});
|
||||
data.channels.filter((c) => c.enabled !== false && c.running !== true && c.connected !== true).forEach((c) => {
|
||||
items.push({
|
||||
priority: 2, border: '#eab308',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">⚠ Channel offline: ${esc(c.name || c.id || c.platform || 'channel')}</div></div>
|
||||
<button data-nav="channels" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Open</button>`,
|
||||
});
|
||||
});
|
||||
if (data.agents.length === 0) {
|
||||
items.push({
|
||||
priority: 4, border: 'var(--db-accent,#6366f1)',
|
||||
html: `<div style="flex:1"><div style="font-weight:500">No agents configured</div></div>
|
||||
<button data-nav="agents" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Configure</button>`,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.priority - b.priority);
|
||||
return items.slice(0, 8);
|
||||
}
|
||||
|
||||
function renderAttention(data) {
|
||||
const items = buildAttention(data);
|
||||
if (items.length === 0) {
|
||||
return `<div style="padding:20px;color:var(--db-text-dim);font-size:13px;text-align:center">✓ All clear — nothing needs attention</div>`;
|
||||
}
|
||||
return items.map((it) => `<div class="db-card" style="display:flex;align-items:center;gap:10px;padding:12px 14px;margin-bottom:8px;border-left:3px solid ${it.border}">${it.html}</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTimeline(traces) {
|
||||
if (traces.length === 0) {
|
||||
return `<div style="padding:16px;color:var(--db-text-dim);font-size:13px">No recent activity</div>`;
|
||||
}
|
||||
return traces.slice(0, 10).map((t) => {
|
||||
const label = t.name || t.event || t.tool || t.type || 'event';
|
||||
const agent = t.agent_name || t.agent || '';
|
||||
const when = timeAgo(t.timestamp || t.created_at || t.start_time);
|
||||
return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px">
|
||||
<span style="color:var(--db-text-dim);font-size:11px;min-width:64px">${esc(when)}</span>
|
||||
<span style="flex:1">${esc(label)}</span>
|
||||
${agent ? `<span style="color:var(--db-text-dim);font-size:12px">${esc(agent)}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const OVERVIEW_STYLE = `<style id="ov-style">
|
||||
@media (max-width: 768px) {
|
||||
.db-metric-grid { grid-template-columns: repeat(2, 1fr) !important; }
|
||||
.db-ov-columns { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.db-sparkline polyline, .db-sparkline polygon { transition: none !important; }
|
||||
}
|
||||
</style>`;
|
||||
|
||||
function shell(data) {
|
||||
return `${OVERVIEW_STYLE}
|
||||
<div class="db-omnibar" data-omnibar="1" role="button" tabindex="0" style="display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:18px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:10px;cursor:pointer;color:var(--db-text-dim);max-width:520px">
|
||||
<span>🔍</span><span style="flex:1;font-size:13px">Search pages, agents, sessions…</span>
|
||||
<span style="font-size:11px;padding:2px 8px;border:1px solid var(--db-border);border-radius:6px">Ctrl+K</span>
|
||||
</div>
|
||||
|
||||
<div id="ov-metrics">${renderMetrics(data)}</div>
|
||||
|
||||
<div class="db-ov-columns" style="display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-top:20px">
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Active Agents</h3>
|
||||
<div id="ov-agents">${renderAgentTable(data.agents)}</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Features</div>
|
||||
<div class="db-card-value">${total}</div>
|
||||
<div class="db-card-footer">${healthy} active</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">Health</div>
|
||||
<div class="db-card-value">${total > 0 ? Math.round(healthy/total*100) : 0}%</div>
|
||||
<div class="db-card-footer">${total - healthy} degraded</div>
|
||||
</div>
|
||||
<div class="db-card">
|
||||
<div class="db-card-title">API Latency</div>
|
||||
<div class="db-card-value">${Date.now() - startTime}ms</div>
|
||||
<div class="db-card-footer">/api/features</div>
|
||||
<div>
|
||||
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Needs Attention</h3>
|
||||
<div id="ov-attention">${renderAttention(data)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin:28px 0 16px;font-size:15px;font-weight:600">Feature Health</h3>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">
|
||||
${features.map(f => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:500">${f.name || 'unknown'}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${f.description || ''}</div>
|
||||
</div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<h3 style="margin:24px 0 12px;font-size:15px;font-weight:600">Recent Activity</h3>
|
||||
<div id="ov-timeline" class="db-card" style="padding:6px 16px">${renderTimeline(data.traces)}</div>
|
||||
|
||||
<details style="margin-top:24px">
|
||||
<summary style="cursor:pointer;font-size:15px;font-weight:600;margin-bottom:12px">Feature Health</summary>
|
||||
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:12px">
|
||||
${data.features.map((f) => `
|
||||
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
|
||||
<div><div style="font-size:13px;font-weight:500">${esc(f.name || 'unknown')}</div>
|
||||
<div style="font-size:11px;color:var(--db-text-dim)">${esc(f.description || '')}</div></div>
|
||||
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
|
||||
? 'background:rgba(34,197,94,0.15);color:#22c55e'
|
||||
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="ov-drawer"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openDrawer(agent) {
|
||||
const host = _container?.querySelector('#ov-drawer');
|
||||
if (!host) return;
|
||||
const id = agent.id || agent.name || '';
|
||||
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
||||
host.innerHTML = `
|
||||
<div class="ov-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200">
|
||||
<div style="position:absolute;top:0;right:0;height:100%;width:400px;max-width:92vw;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);padding:24px;overflow-y:auto;box-sizing:border-box">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:18px">${esc(agent.name || id)}</h2>
|
||||
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(agent.model || '—')}</span>
|
||||
${statusDot(agent.status)}<span style="font-size:12px;color:var(--db-text-dim)">${esc(agent.status || 'idle')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ov-drawer-close" style="background:none;border:none;color:var(--db-text-dim);font-size:20px;cursor:pointer">×</button>
|
||||
</div>
|
||||
<div style="margin-top:20px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Instructions</div>
|
||||
<div style="font-size:13px;line-height:1.6;color:var(--db-text)">${esc((agent.instructions || agent.description || '—').slice(0, 400))}</div>
|
||||
</div>
|
||||
${tools.length ? `<div style="margin-top:18px">
|
||||
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Tools</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">${tools.map((t) => `<span style="font-size:11px;padding:3px 9px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(typeof t === 'string' ? t : t.name || 'tool')}</span>`).join('')}</div>
|
||||
</div>` : ''}
|
||||
<div style="margin-top:24px;display:flex;gap:10px">
|
||||
<button class="ov-drawer-chat" data-agent-id="${esc(id)}" style="flex:1;padding:9px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Chat now</button>
|
||||
<button class="ov-drawer-traces" style="padding:9px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">View traces</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => { host.innerHTML = ''; };
|
||||
host.querySelector('.ov-drawer-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
host.querySelector('.ov-drawer-close').addEventListener('click', close);
|
||||
host.querySelector('.ov-drawer-chat')?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('aiui:select-agent', { detail: { agentId: id } }));
|
||||
navigate('chat');
|
||||
});
|
||||
host.querySelector('.ov-drawer-traces')?.addEventListener('click', () => { close(); navigate('traces'); });
|
||||
}
|
||||
|
||||
function bindEvents(container, data) {
|
||||
const agentById = (id) => data.agents.find((a) => (a.id || a.name) === id);
|
||||
|
||||
container.querySelectorAll('[data-nav]').forEach((el) => {
|
||||
const go = () => navigate(el.getAttribute('data-nav'));
|
||||
el.addEventListener('click', go);
|
||||
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
||||
});
|
||||
|
||||
container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
|
||||
|
||||
container.querySelectorAll('.db-agent-row').forEach((row) => {
|
||||
row.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.db-agent-chat')) return;
|
||||
const a = agentById(row.dataset.agentId);
|
||||
if (a) openDrawer(a);
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.db-agent-chat').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(new CustomEvent('aiui:select-agent', { detail: { agentId: btn.dataset.agentId } }));
|
||||
navigate('chat');
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.atn-approve').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/approve`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
container.querySelectorAll('.atn-deny').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
try { await fetch(`/api/approvals/${btn.dataset.id}/deny`, { method: 'POST' }); } catch (e) { /* ignore */ }
|
||||
refreshApprovals();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function paint() {
|
||||
if (!_container) return;
|
||||
const data = await loadAll();
|
||||
_container.__ovData = data;
|
||||
_container.innerHTML = shell(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
if (!_container || document.hidden) return;
|
||||
let pending = [];
|
||||
try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
|
||||
const data = _container.__ovData;
|
||||
if (!data) return;
|
||||
data.pending = pending;
|
||||
const metricsEl = _container.querySelector('#ov-metrics');
|
||||
const attnEl = _container.querySelector('#ov-attention');
|
||||
if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
|
||||
if (attnEl) attnEl.innerHTML = renderAttention(data);
|
||||
bindEvents(_container, data);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
_metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
|
||||
_approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
|
||||
_visibilityHandler = () => { if (!document.hidden) refreshApprovals(); };
|
||||
document.addEventListener('visibilitychange', _visibilityHandler);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (_metricsTimer) { clearInterval(_metricsTimer); _metricsTimer = null; }
|
||||
if (_approvalsTimer) { clearInterval(_approvalsTimer); _approvalsTimer = null; }
|
||||
if (_visibilityHandler) { document.removeEventListener('visibilitychange', _visibilityHandler); _visibilityHandler = null; }
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
_container = container;
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
await paint();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
stopPolling();
|
||||
_container = null;
|
||||
}
|
||||
Reference in new issue
Block a user