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>
294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
(function () {
|
||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
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 `há ${m} min`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `há ${h}h`;
|
||
return `há ${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 A0–A7</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;
|
||
})();
|