ligbox-ops-platform/projects/ops-desk/frontend/assets/agentic-ops.js
Ligbox Spec Hub fd491e5859 Implement Spec 030 Agentic Ops Mission Board (UI-A/B/C).
Add agent_incidents dedup, overview/incidents/timeline API, mission board UI with fleet rail, kanban, context panel, mobile tabs, poll and keyboard shortcuts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 06:49:38 +00:00

294 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const SEV_COLS = [
{ key: 'critical', label: 'Crítico', cls: 'ao-board-col--critical' },
{ key: 'high', label: 'Alto', cls: 'ao-board-col--high' },
{ key: 'warn', label: 'Aviso', cls: 'ao-board-col--warn' },
{ key: 'info', label: 'Info / OK', cls: 'ao-board-col--ok' },
];
const AGENT_ACCENTS = {
A0: '#6366f1', A1: '#22c55e', A2: '#3b82f6', A3: '#06b6d4', A4: '#8b5cf6',
A5: '#ec4899', A6: '#a855f7', A7: '#ef4444', sentinel: '#f59e0b', curator: '#64748b',
};
let state = {
selectedAgent: 'A6',
selectedIncidentId: null,
threadId: null,
mobileTab: 'board',
pollTimer: null,
};
async function agentsApi(path, opts = {}) {
const deskApi = typeof globalThis.api === 'function' ? globalThis.api : null;
if (deskApi) return deskApi(`/v1/agents${path}`, opts);
const h = authHeaders({ ...(opts.headers || {}) });
if (!(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json';
const r = await fetchWithTimeout(`/api/v1/agents${path}`, { ...opts, headers: h }, 60000);
if (r.status === 401) {
logout();
throw new Error('sessão expirada — faça login novamente');
}
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
return r.json();
}
function fmtAge(iso) {
if (!iso) return '—';
try {
const ms = Date.now() - new Date(iso).getTime();
const m = Math.floor(ms / 60000);
if (m < 1) return 'agora';
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
} catch {
return iso;
}
}
function incidentCard(inc) {
const active = state.selectedIncidentId === inc.id ? ' ao-incident-card--active' : '';
return `<article class="ao-incident-card${active}" data-incident-id="${inc.id}" data-thread-id="${inc.thread_id || ''}">
<div class="ao-incident-title">${esc(inc.title)}</div>
<div class="ao-incident-meta">${esc(inc.agent_name)} · ${esc(inc.scenario_id)} · ${fmtAge(inc.last_seen_at)} · ${inc.occurrence_count || 1}×</div>
<div class="ao-incident-action">${esc((inc.suggested_human_action || 'Investigar manualmente.').slice(0, 160))}</div>
<div class="ao-incident-actions">
<button type="button" class="btn btn-primary btn-sm" data-open-incident="${inc.id}">Abrir</button>
<button type="button" class="btn btn-ghost btn-sm" data-ack-incident="${inc.id}">Ack</button>
</div>
</article>`;
}
function fleetItem(a, openAgents) {
const active = state.selectedAgent === a.id ? ' ao-fleet-item--active' : '';
const pulse = openAgents.has(a.id) ? ' ao-fleet-item--pulse' : '';
const dotCls = openAgents.has(a.id) ? ' ao-fleet-dot--active' : '';
const accent = AGENT_ACCENTS[a.id] || '#64748b';
return `<div class="ao-fleet-item${active}${pulse}" data-agent-id="${esc(a.id)}" role="button" tabindex="0">
<span class="ao-fleet-dot${dotCls}" style="background:${accent}"></span>
<span>${esc(a.name)}</span>
<span class="pill pill-sm">${esc(a.id)}</span>
</div>`;
}
function threadBubble(m) {
const cls = m.from_type === 'human' ? 'ao-bubble-human' : 'ao-bubble-agent';
return `<div class="ao-bubble ${cls}">
<div class="ao-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}</div>
<div>${esc(m.body).replace(/\n/g, '<br>')}</div>
</div>`;
}
async function loadThread(el, threadId) {
state.threadId = threadId;
const box = el.querySelector('#ao-thread-messages');
if (!box || !threadId) return;
box.innerHTML = '<p class="loading">Carregando…</p>';
const data = await agentsApi(`/threads/${threadId}/messages`);
box.innerHTML = data.messages.map(threadBubble).join('') || '<p class="empty">Sem mensagens.</p>';
box.scrollTop = box.scrollHeight;
}
function bindShell(el) {
el.querySelector('#ao-btn-refresh')?.addEventListener('click', () => renderAgenticOps());
el.querySelectorAll('[data-agent-id]').forEach((node) => {
node.addEventListener('click', () => {
state.selectedAgent = node.dataset.agentId;
renderAgenticOps();
});
});
el.querySelectorAll('[data-incident-id], [data-open-incident]').forEach((node) => {
node.addEventListener('click', async (ev) => {
if (ev.target.closest('[data-ack-incident]')) return;
const id = parseInt(node.dataset.incidentId || node.dataset.openIncident, 10);
state.selectedIncidentId = id;
const card = el.querySelector(`[data-incident-id="${id}"]`);
const tid = parseInt(card?.dataset.threadId, 10);
if (tid) await loadThread(el, tid);
renderAgenticOps();
});
});
el.querySelectorAll('[data-ack-incident]').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
await agentsApi(`/incidents/${btn.dataset.ackIncident}/ack`, { method: 'POST' });
if (state.selectedIncidentId === parseInt(btn.dataset.ackIncident, 10)) {
state.selectedIncidentId = null;
state.threadId = null;
}
await renderAgenticOps();
});
});
el.querySelector('#ao-btn-reply')?.addEventListener('click', async () => {
const input = el.querySelector('#ao-reply-input');
const tid = state.threadId;
const body = (input?.value || '').trim();
if (!tid || !body) return;
await agentsApi(`/threads/${tid}/reply`, {
method: 'POST',
body: JSON.stringify({ body, target_agent: state.selectedAgent }),
});
input.value = '';
await loadThread(el, tid);
});
el.querySelector('#ao-btn-chat')?.addEventListener('click', async () => {
const input = el.querySelector('#ao-chat-input');
const out = el.querySelector('#ao-chat-answer');
const q = (input?.value || '').trim();
if (!q) return;
out.hidden = false;
out.innerHTML = '<p class="loading">A pensar…</p>';
try {
const res = await agentsApi('/chat', {
method: 'POST',
body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }),
});
out.innerHTML = `<p><strong>${esc(state.selectedAgent)}</strong> <span class="ticket-meta">(${esc(res.model)})</span></p><p>${esc(res.answer)}</p>`;
if (res.thread_id) {
state.threadId = res.thread_id;
await loadThread(el, res.thread_id);
}
} catch (err) {
out.innerHTML = `<p class="error">${esc(err.message)}</p>`;
}
});
el.querySelectorAll('.ao-mobile-tabs button').forEach((btn) => {
btn.addEventListener('click', () => {
state.mobileTab = btn.dataset.aoTab;
el.querySelectorAll('.ao-mobile-tabs button').forEach((b) => b.classList.toggle('active', b === btn));
el.querySelectorAll('.ao-pane').forEach((p) => {
p.classList.toggle('ao-pane--active', p.dataset.aoPane === state.mobileTab);
});
});
});
}
function schedulePoll() {
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = setInterval(() => {
if (document.hidden) return;
const view = document.getElementById('view-agentic-ops');
if (view && !view.hidden) renderAgenticOps({ poll: true });
}, 30000);
}
async function renderAgenticOps(options = {}) {
const el = document.getElementById('agentic-ops-content');
if (!el) return;
if (!options.poll) {
el.innerHTML = '<div class="ao-status-bar"><p class="loading">Carregando Mission Board…</p></div>'
+ '<div class="ao-board"><div class="ao-skeleton"></div><div class="ao-skeleton"></div></div>';
}
if (!getToken()) {
el.innerHTML = '<p class="error">Sessão não encontrada. <a href="/login.html">Fazer login</a></p>';
return;
}
if (!options.poll && typeof ensureValidSession === 'function') {
const ok = await ensureValidSession();
if (!ok) {
el.innerHTML = '<p class="error">Sessão expirada. <a href="/login.html">Fazer login</a></p>';
return;
}
}
try {
const [overview, incidents, roster, timeline] = await Promise.all([
agentsApi('/overview'),
agentsApi('/incidents?status=open&limit=50'),
agentsApi('/roster'),
agentsApi('/timeline?limit=12'),
]);
const list = incidents.incidents || [];
const openAgents = new Set(list.map((i) => i.primary_agent));
const filtered = state.selectedAgent && state.selectedAgent !== 'ALL'
? list.filter((i) => i.primary_agent === state.selectedAgent)
: list;
const bySev = { critical: [], high: [], warn: [], info: [] };
filtered.forEach((inc) => {
const k = bySev[inc.severity] ? inc.severity : 'warn';
(bySev[k] || bySev.warn).push(inc);
});
const sel = list.find((i) => i.id === state.selectedIncidentId);
const ollamaPill = overview.ollama
? `<span class="pill pill-ok">Ollama · ${esc(overview.model)}</span>`
: '<span class="pill pill-warn">Ollama offline</span>';
const tier = overview.tier === 't1' ? 'T1 LLM' : 'T0';
const open = overview.incidents_open || {};
const boardCols = SEV_COLS.map((col) => {
const cards = (bySev[col.key] || []).map(incidentCard).join('')
|| '<p class="ticket-meta" style="font-size:0.75rem">—</p>';
return `<div class="ao-board-col ${col.cls}"><h4>${col.label}</h4>${cards}</div>`;
}).join('');
const fleet = (roster.agents || []).map((a) => fleetItem(a, openAgents)).join('');
const ticks = (timeline.ticks || []).map((t) =>
`<div class="ao-timeline-row"><span>${esc(fmtAge(t.at))}</span><span>${t.scenarios || '—'} cenários · ${t.findings || 0} findings</span></div>`
).join('') || '<p class="ticket-meta">Sem ticks recentes.</p>';
el.innerHTML = `
<div class="ao-status-bar">
<div>
<h2 style="margin:0;font-size:1.1rem">Agentic Ops</h2>
<div class="ao-status-metrics">
<span class="pill">${tier}</span> ${ollamaPill}
<span>Último tick: ${fmtAge(overview.last_tick_at)}</span>
<span>${overview.scenarios_ok || 0}/${overview.scenarios_total || 9} cenários OK</span>
<span>Abertos: ${open.high || 0} alto · ${open.warn || 0} aviso</span>
</div>
</div>
<button type="button" class="btn btn-primary btn-sm" id="ao-btn-refresh">Actualizar (R)</button>
</div>
<div class="ao-mobile-tabs">
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'board' ? ' active' : ''}" data-ao-tab="board">Board</button>
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'fleet' ? ' active' : ''}" data-ao-tab="fleet">Frota</button>
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'context' ? ' active' : ''}" data-ao-tab="context">Contexto</button>
</div>
<div class="ao-shell">
<aside class="ao-fleet-rail ao-pane${state.mobileTab === 'fleet' ? ' ao-pane--active' : ''}" data-ao-pane="fleet">
<h3>Frota A0A7</h3>
<div class="ao-fleet-item${state.selectedAgent === 'ALL' ? ' ao-fleet-item--active' : ''}" data-agent-id="ALL" role="button"><span class="ao-fleet-dot"></span><span>Todos</span></div>
${fleet}
</aside>
<section class="ao-pane${state.mobileTab === 'board' ? ' ao-pane--active' : ''}" data-ao-pane="board">
<div class="ao-board">${filtered.length ? boardCols : '<div class="ao-empty"><p>✓ Ambiente saudável — nenhum incidente aberto.</p></div>'}</div>
<div class="ao-timeline"><h4 style="margin:0 0 0.5rem;font-size:0.8rem">Timeline ticks (24h)</h4>${ticks}</div>
</section>
<aside class="card ao-context-panel ao-pane${state.mobileTab === 'context' ? ' ao-pane--active' : ''}" data-ao-pane="context">
<h3>Janela de contexto</h3>
${sel ? `<p class="ticket-meta"><strong>${esc(sel.title)}</strong><br>${esc(sel.scenario_id)} · ${esc(sel.agent_name)}</p>` : '<p class="ticket-meta">Seleccione um incidente no board.</p>'}
<div id="ao-thread-messages" class="ao-thread-messages">${state.threadId ? '<p class="loading">…</p>' : '<p class="empty">Nenhuma thread seleccionada.</p>'}</div>
<textarea id="ao-reply-input" rows="2" class="input" placeholder="Responder ao agente…"></textarea>
<button type="button" class="btn btn-primary btn-sm" id="ao-btn-reply">Enviar resposta</button>
<hr style="margin:0.75rem 0;opacity:0.25">
<h4>Copiloto (${esc(state.selectedAgent)})</h4>
<textarea id="ao-chat-input" rows="2" class="input" placeholder="Pergunta ao agente…"></textarea>
<button type="button" class="btn btn-ghost btn-sm" id="ao-btn-chat">Perguntar</button>
<div id="ao-chat-answer" class="ticket-meta" hidden></div>
</aside>
</div>`;
bindShell(el);
if (state.threadId) await loadThread(el, state.threadId);
else if (sel?.thread_id) await loadThread(el, sel.thread_id);
if (!state.pollTimer) schedulePoll();
} catch (err) {
if (!options.poll) el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
}
}
document.addEventListener('keydown', (ev) => {
if (ev.key === 'r' && !ev.ctrlKey && !ev.metaKey && document.getElementById('view-agentic-ops') && !document.getElementById('view-agentic-ops').hidden) {
const t = ev.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
ev.preventDefault();
renderAgenticOps();
}
if (ev.key === 'Escape') {
state.selectedIncidentId = null;
state.threadId = null;
renderAgenticOps();
}
});
window.renderAgenticOps = renderAgenticOps;
})();