Commita governance, user-wizard, operational-feed e catálogo RBAC; adiciona deploy-desk-full.sh, smoke-desk.sh e regra anti-deploy parcial; documenta credencial VM112 @betinplace. Co-authored-by: Cursor <cursoragent@cursor.com>
466 lines
18 KiB
JavaScript
466 lines
18 KiB
JavaScript
/**
|
|
* Results and Works Desk — UI aprovada (Spec 032 / Roger 2026-06-28)
|
|
* KPI Overview + tabela operacional + alertas agentic.
|
|
*/
|
|
const TicketsWorkspace = {
|
|
context: null,
|
|
queueFilter: null,
|
|
priorityFilter: null,
|
|
_pageReady: false,
|
|
_listFingerprint: '',
|
|
|
|
async loadContext() {
|
|
const [presence, funnel, summary] = await Promise.all([
|
|
window.DeskLive?.enabled()
|
|
? api('/v1/live/presence').catch(() => ({ sessions: [] }))
|
|
: Promise.resolve({ sessions: [] }),
|
|
api('/v1/onboard/funnel?window_hours=48').catch(() => ({ active_sessions: [] })),
|
|
api('/v1/desk/summary').catch(() => ({})),
|
|
]);
|
|
const liveBySession = {};
|
|
for (const s of presence.sessions || []) {
|
|
if (s.session_id) liveBySession[s.session_id] = s;
|
|
}
|
|
const funnelBySession = {};
|
|
for (const s of funnel.active_sessions || []) {
|
|
if (s.session_id) funnelBySession[s.session_id] = s;
|
|
}
|
|
this.context = {
|
|
liveBySession,
|
|
funnelBySession,
|
|
staleHours: summary.onboard_stale_hours ?? 24,
|
|
summary,
|
|
};
|
|
return this.context;
|
|
},
|
|
|
|
stripEnrichment(t) {
|
|
const out = { ...t };
|
|
for (const k of Object.keys(out)) {
|
|
if (k.startsWith('_')) delete out[k];
|
|
}
|
|
return out;
|
|
},
|
|
|
|
enrichTicket(t) {
|
|
const ctx = this.context || { liveBySession: {}, funnelBySession: {}, staleHours: 24 };
|
|
const sid = (t.session_id || '').trim();
|
|
const funnel = sid ? ctx.funnelBySession[sid] : null;
|
|
const isActive = ['open', 'escalated', 'assisting', 'resolved'].includes(t.status);
|
|
const lastAt = funnel?.last_event_at || t.assisted_at || t.created_at;
|
|
let idleHours = 0;
|
|
if (lastAt) idleHours = (Date.now() - new Date(lastAt).getTime()) / 3600000;
|
|
const ageHours = t.created_at
|
|
? (Date.now() - new Date(t.created_at).getTime()) / 3600000
|
|
: 0;
|
|
const stale = funnel?.stale || (isActive && idleHours >= ctx.staleHours);
|
|
const sev = Number(t.severity);
|
|
const isWazuh = t.source === 'wazuh' || t.event === 'wazuh.alert';
|
|
let priority = 'info';
|
|
if (sev >= 12 || (isWazuh && isActive && sev >= 10)) priority = 'critical';
|
|
else if (sev >= 10 || t.status === 'escalated') priority = 'high';
|
|
else if (sev >= 7 || stale) priority = 'warn';
|
|
|
|
let voScore = 3;
|
|
if (sev >= 12) voScore = 10;
|
|
else if (sev >= 10) voScore = 8;
|
|
else if (sev >= 7) voScore = 6;
|
|
else if (stale) voScore = 5;
|
|
else if (t.status === 'escalated') voScore = 7;
|
|
else if (t.status === 'open') voScore = 4;
|
|
|
|
const live = sid ? !!ctx.liveBySession[sid] : false;
|
|
|
|
let base = {
|
|
...t,
|
|
_idleHours: Math.round(idleHours),
|
|
_ageHours: Math.round(ageHours),
|
|
_stale: stale && isActive,
|
|
_live: live,
|
|
_lastEventAt: funnel?.last_event_at || null,
|
|
_unassigned: isActive && !t.assigned_to && !t.assisted_by,
|
|
_wazuh: isWazuh,
|
|
_security: isWazuh || (t.subject || '').toLowerCase().includes('security'),
|
|
_priority: priority,
|
|
_voScore: voScore,
|
|
_stageLabel: typeof FUNNEL_LABELS !== 'undefined'
|
|
? (FUNNEL_LABELS[funnel?.current_stage] || funnel?.current_stage || '')
|
|
: '',
|
|
};
|
|
if (window.TicketsSla?.computePriority) {
|
|
base = TicketsSla.computePriority(base);
|
|
}
|
|
return base;
|
|
},
|
|
|
|
formatMetric(val) {
|
|
if (val === '—' || val == null || val === '') return '—';
|
|
const n = parseFloat(val);
|
|
return Number.isFinite(n) ? `${n.toFixed(1)}h` : String(val);
|
|
},
|
|
|
|
processTickets(tickets) {
|
|
const enriched = tickets.map((t) => this.enrichTicket(this.stripEnrichment(t)));
|
|
if (window.TicketsSla?.processList) return TicketsSla.processList(enriched);
|
|
return enriched.sort((a, b) => (b._priorityScore || b._voScore || 0) - (a._priorityScore || a._voScore || 0));
|
|
},
|
|
|
|
computeMetrics(tickets) {
|
|
const enriched = this.processTickets(tickets);
|
|
let overview = null;
|
|
if (window.TicketsSla?.computeOverview) {
|
|
overview = TicketsSla.computeOverview(enriched);
|
|
}
|
|
const priorities = overview?.buckets || { critical: 0, high: 0, warn: 0, info: 0 };
|
|
const mttrNum = overview ? (overview.mttr === '—' ? 0 : parseFloat(overview.mttr) || 0) : 0;
|
|
const respNum = overview ? (overview.avgResponse === '—' ? 0 : parseFloat(overview.avgResponse) || 0) : 0;
|
|
if (!overview) {
|
|
for (const t of enriched) priorities[t._priorityBand || t._priority] = (priorities[t._priorityBand || t._priority] || 0) + 1;
|
|
}
|
|
return {
|
|
enriched,
|
|
slaPct: overview?.slaPct ?? 0,
|
|
mttr: overview?.mttr ?? '0',
|
|
resp: overview?.avgResponse ?? '0',
|
|
mttrNum,
|
|
respNum,
|
|
priorities,
|
|
criticalWaiting: overview?.criticalWaiting ?? priorities.critical ?? 0,
|
|
maxPriority: Math.max(priorities.critical || 0, priorities.high || 0, priorities.warn || 0, priorities.info || 0, 1),
|
|
};
|
|
},
|
|
|
|
gaugeHtml(pct) {
|
|
const p = Math.max(0, Math.min(100, Number(pct) || 0));
|
|
const r = 42;
|
|
const circ = Math.PI * r;
|
|
const filled = ((p / 100) * circ).toFixed(2);
|
|
const uid = `rwdG${Math.random().toString(36).slice(2, 8)}`;
|
|
return `
|
|
<div class="rwd-gauge" role="img" aria-label="SLA ${p}% compliant">
|
|
<svg viewBox="0 0 124 72" class="rwd-gauge__svg">
|
|
<defs>
|
|
<linearGradient id="${uid}" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
<stop offset="0%" stop-color="#1f4d45"/>
|
|
<stop offset="50%" stop-color="#5a8f3c"/>
|
|
<stop offset="100%" stop-color="#c9a227"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path class="rwd-gauge__track" d="M 20 60 A 42 42 0 0 1 104 60" fill="none" stroke="#d8d2c8" stroke-width="11" stroke-linecap="round"/>
|
|
${p > 0 ? `<path class="rwd-gauge__fill" d="M 20 60 A 42 42 0 0 1 104 60" fill="none" stroke="url(#${uid})" stroke-width="11" stroke-linecap="round"
|
|
stroke-dasharray="${filled} ${circ.toFixed(2)}" pathLength="${circ.toFixed(2)}"/>` : ''}
|
|
<circle cx="20" cy="60" r="5" fill="#1f4d45"/>
|
|
<circle cx="104" cy="60" r="5" fill="#c9a227"/>
|
|
</svg>
|
|
<span class="rwd-gauge__pct">${p}%</span>
|
|
</div>`;
|
|
},
|
|
|
|
priorityBarsHtml(priorities, max) {
|
|
const items = [
|
|
{ key: 'critical', label: 'Critical', count: priorities.critical || 0 },
|
|
{ key: 'high', label: 'High', count: priorities.high || 0 },
|
|
{ key: 'warn', label: 'Warn', count: priorities.warn || 0 },
|
|
{ key: 'info', label: 'Info', count: priorities.info || 0 },
|
|
];
|
|
const maxVal = Math.max(15, max, ...items.map((i) => i.count), 1);
|
|
return `
|
|
<div class="rwd-vbars" role="img" aria-label="Tickets by priority">
|
|
${items.map((it) => {
|
|
const h = it.count > 0 ? Math.max(16, Math.round((it.count / maxVal) * 100)) : 8;
|
|
return `
|
|
<div class="rwd-vbars__col">
|
|
<span class="rwd-vbars__count">${it.count}</span>
|
|
<div class="rwd-vbars__track">
|
|
<div class="rwd-vbars__bar rwd-vbars__bar--${it.key}" style="height:${h}%"></div>
|
|
</div>
|
|
<span class="rwd-vbars__label">${it.label}</span>
|
|
</div>`;
|
|
}).join('')}
|
|
</div>`;
|
|
},
|
|
|
|
kpiOverviewHtml(m) {
|
|
return `
|
|
<section class="rwd-kpi-section" aria-label="KPI Overview">
|
|
<h3 class="rwd-kpi-section__title">KPI Overview</h3>
|
|
<div class="rwd-kpi-grid">
|
|
<article class="rwd-kpi-card rwd-kpi-card--gauge">
|
|
<span class="rwd-kpi-card__label">% SLA Compliant (General)</span>
|
|
<div class="rwd-kpi-card__body rwd-kpi-card__body--gauge">
|
|
${this.gaugeHtml(m.slaPct)}
|
|
</div>
|
|
</article>
|
|
<article class="rwd-kpi-card rwd-kpi-card--metric">
|
|
<span class="rwd-kpi-card__label">Avg. Resolution Time (MTTR)</span>
|
|
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
|
|
<span class="rwd-kpi-card__value">${this.formatMetric(m.mttr)}</span>
|
|
</div>
|
|
</article>
|
|
<article class="rwd-kpi-card rwd-kpi-card--metric">
|
|
<span class="rwd-kpi-card__label">Avg. Response Time</span>
|
|
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
|
|
<span class="rwd-kpi-card__value">${this.formatMetric(m.resp)}</span>
|
|
</div>
|
|
</article>
|
|
<article class="rwd-kpi-card rwd-kpi-card--bars">
|
|
<span class="rwd-kpi-card__label">Tickets by Priority</span>
|
|
<div class="rwd-kpi-card__body rwd-kpi-card__body--bars">
|
|
${this.priorityBarsHtml(m.priorities, m.maxPriority)}
|
|
</div>
|
|
</article>
|
|
<article class="rwd-kpi-card rwd-kpi-card--metric">
|
|
<span class="rwd-kpi-card__label">Critical Waiting</span>
|
|
<div class="rwd-kpi-card__body rwd-kpi-card__body--metric">
|
|
<span class="rwd-kpi-card__value rwd-kpi-card__value--danger">${m.criticalWaiting}</span>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>`;
|
|
},
|
|
|
|
headerHtml() {
|
|
return `
|
|
<header class="rwd-header">
|
|
<div class="rwd-header__copy">
|
|
<p class="rwd-header__label">Sessão Tickets</p>
|
|
<h2>Results and Works Desk</h2>
|
|
<p class="rwd-header__sub">Operações Ligbox — onboarding, tickets e monitoramento</p>
|
|
</div>
|
|
<div class="rwd-header__actions">
|
|
<button type="button" class="btn btn-ghost btn-sm" id="rwd-filter-toggle">Filter</button>
|
|
<button type="button" class="btn btn-primary btn-sm" id="rwd-refresh">Atualizar</button>
|
|
</div>
|
|
</header>`;
|
|
},
|
|
|
|
tenantCell(t) {
|
|
const name = t.domain || t.agent || (t.subject || '').slice(0, 32) || `Ticket #${t.id}`;
|
|
const tags = [
|
|
t._tenant === 'SECURITY' || t._security ? '<span class="rwd-tag rwd-tag--security">SECURITY</span>' : '',
|
|
t._wazuh ? '<span class="rwd-tag rwd-tag--security">WAZUH</span>' : '',
|
|
t._tenant === 'ONBOARD' || t.source === 'vm112-onboard' ? '<span class="rwd-tag rwd-tag--onboard">ONBOARD</span>' : '',
|
|
].filter(Boolean).join('');
|
|
const resp = t._responseSla || (t.assisted_at ? 'OK' : (t._stale ? 'WARNING' : 'PENDING'));
|
|
const res = t._resolutionSla || (t.status === 'closed' || t.status === 'resolved' ? 'OK' : (t._stale ? 'STALE (4h+)' : 'OPEN'));
|
|
return `
|
|
<div>${tags}<strong>${esc(name)}</strong></div>
|
|
<div class="rwd-sla-line">Resp: ${resp} · Res: ${res}</div>`;
|
|
},
|
|
|
|
detailsCell(t) {
|
|
const parts = [];
|
|
if (t.agent) parts.push(esc(t.agent));
|
|
parts.push(`ID: ${t.id}`);
|
|
if (t.severity != null) parts.push(`Wazuh Lvl ${t.severity}`);
|
|
else if (t._stageLabel) parts.push(esc(t._stageLabel));
|
|
else if (t.event) parts.push(esc(String(t.event).replace('onboarding.', '')));
|
|
return `
|
|
<div class="rwd-detail-title">${esc(t.description || t.subject || parts[0] || '—')}</div>
|
|
<div class="rwd-detail-sub">${parts.join(' · ')}</div>`;
|
|
},
|
|
|
|
scoreCell(t) {
|
|
const stale = t._stale ? `<span class="rwd-status-pill rwd-status-pill--stale">PARADO ${t._idleHours}H</span>` : '';
|
|
const st = t.status || 'open';
|
|
const live = t._live
|
|
? '<span class="rwd-status-pill rwd-status-pill--live">LIVE</span>'
|
|
: '<span class="rwd-status-pill rwd-status-pill--off">OFF</span>';
|
|
const score = t._priorityScore ?? t._voScore ?? '—';
|
|
return `
|
|
<div class="rwd-score-col">
|
|
<span class="rwd-status-pill rwd-status-pill--${st}">${esc(statusLabel(st))}</span>
|
|
${live}
|
|
${stale}
|
|
<span class="rwd-vo-score" title="Priority Score v0">${score}</span>
|
|
<span class="ticket-meta">V0 SCORE</span>
|
|
</div>`;
|
|
},
|
|
|
|
tableHtml(tickets) {
|
|
if (!tickets.length) {
|
|
return '<p class="loading" style="padding:1rem">Nenhum ticket neste filtro</p>';
|
|
}
|
|
const rows = tickets.map((t) => {
|
|
const sel = state.selectedTicketId === t.id ? ' selected' : '';
|
|
return `
|
|
<tr class="rwd-row${sel}" data-id="${t.id}"${t.session_id ? ` data-session="${esc(t.session_id)}"` : ''}>
|
|
<td>${this.scoreCell(t)}</td>
|
|
<td>${this.tenantCell(t)}</td>
|
|
<td>${this.detailsCell(t)}</td>
|
|
<td><span class="${t._idleHours >= 4 ? 'rwd-idle-warn' : ''}">${t._idleHours}h</span><br><span class="ticket-meta">Inativo</span></td>
|
|
<td>${t._ageHours}h<br><span class="ticket-meta">Desde a abertura</span></td>
|
|
<td>${t.assisted_by || t.assigned_to
|
|
? `<strong>${esc(t.assisted_by || t.assigned_to)}</strong>`
|
|
: '<span class="rwd-agent-empty">Sem atribuição</span><br><span class="ticket-meta">AGENT</span>'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
return `
|
|
<div class="rwd-table-wrap">
|
|
<table class="rwd-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Priority Score</th>
|
|
<th>Tenant & SLAs</th>
|
|
<th>Details</th>
|
|
<th>Idle (last event)</th>
|
|
<th>Total Age</th>
|
|
<th>Responsável</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
},
|
|
|
|
applyFilters(tickets) {
|
|
let list = tickets;
|
|
const qf = state.ticketFilter || this.queueFilter || 'all';
|
|
if (this.priorityFilter) {
|
|
list = list.filter((t) => (t._priorityBand || t._priority) === this.priorityFilter);
|
|
}
|
|
if (qf === 'active') {
|
|
list = list.filter((t) => ['open', 'escalated', 'assisting', 'resolved'].includes(t.status));
|
|
} else if (qf && qf !== 'all') {
|
|
list = list.filter((t) => t.status === qf);
|
|
}
|
|
if (state.sourceFilter && state.sourceFilter !== 'all') {
|
|
list = list.filter((t) => t.source === state.sourceFilter);
|
|
}
|
|
return list;
|
|
},
|
|
|
|
async fetchAgenticAlerts() {
|
|
try {
|
|
const data = await api('/v1/agents/findings?open_only=true&limit=6');
|
|
return (data.findings || []).map((f) => ({
|
|
title: f.title || 'Finding',
|
|
sub: `${f.severity || 'warn'} · ${f.category || 'agent'}`,
|
|
id: f.id,
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
alertsHtml(items) {
|
|
if (!items.length) return '';
|
|
return `
|
|
<aside class="rwd-alerts" aria-label="Agentic Alerts Active">
|
|
<div class="rwd-alerts__head">Agentic Alerts Active</div>
|
|
<ul class="rwd-alerts__list">
|
|
${items.map((a) => `
|
|
<li data-goto-agentic="1">
|
|
<strong>${esc(a.title)}</strong>
|
|
<span>${esc(a.sub)} (open)</span>
|
|
</li>`).join('')}
|
|
</ul>
|
|
</aside>`;
|
|
},
|
|
|
|
bindTable(root, tickets) {
|
|
root.querySelectorAll('.rwd-row').forEach((row) => {
|
|
row.addEventListener('click', async () => {
|
|
state.selectedTicketId = Number(row.dataset.id);
|
|
state.selectedSessionId = row.dataset.session || null;
|
|
root.querySelectorAll('.rwd-row').forEach((r) => r.classList.remove('selected'));
|
|
row.classList.add('selected');
|
|
if (window.TicketsDetailPanel?.openDrawer) {
|
|
await TicketsDetailPanel.openDrawer(state.selectedTicketId);
|
|
} else if (typeof renderTicketDetail === 'function') {
|
|
await renderTicketDetail();
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
bindPage(root) {
|
|
root.querySelector('#rwd-refresh')?.addEventListener('click', () => {
|
|
this._pageReady = false;
|
|
if (typeof refresh === 'function') refresh();
|
|
});
|
|
root.querySelector('#rwd-filter-toggle')?.addEventListener('click', () => {
|
|
document.getElementById('view-tickets')?.classList.toggle('rwd-filters-open');
|
|
});
|
|
root.querySelector('[data-goto-agentic]')?.addEventListener('click', () => setView('agentic-ops'));
|
|
root.querySelectorAll('[data-goto-agentic]').forEach((el) => {
|
|
el.addEventListener('click', () => setView('agentic-ops'));
|
|
});
|
|
},
|
|
|
|
syncPageTitle() {
|
|
document.querySelector('.main')?.classList.add('main--rwd-tickets');
|
|
},
|
|
|
|
listFingerprint(tickets) {
|
|
return tickets.map((t) => [t.id, t.status, t._idleHours, t._priority].join(':')).join('|');
|
|
},
|
|
|
|
renderShell(metrics, filtered, alerts) {
|
|
return `
|
|
<div class="rwd-page">
|
|
${this.headerHtml()}
|
|
${this.kpiOverviewHtml(metrics)}
|
|
${this.tableHtml(filtered)}
|
|
${this.alertsHtml(alerts)}
|
|
</div>`;
|
|
},
|
|
|
|
mountRoot() {
|
|
let root = document.getElementById('tickets-rwd-root');
|
|
if (!root) {
|
|
const view = document.getElementById('view-tickets');
|
|
if (!view) return null;
|
|
root = document.createElement('div');
|
|
root.id = 'tickets-rwd-root';
|
|
view.insertBefore(root, view.firstChild);
|
|
}
|
|
return root;
|
|
},
|
|
|
|
async renderListOnly() {
|
|
const root = document.getElementById('tickets-rwd-root');
|
|
if (!root || !state.tickets?.length) return;
|
|
const metrics = this.computeMetrics(state.tickets.map((t) => this.stripEnrichment(t)));
|
|
state.tickets = metrics.enriched;
|
|
const filtered = this.applyFilters(metrics.enriched);
|
|
const fp = this.listFingerprint(filtered);
|
|
if (fp === this._listFingerprint && root.querySelector('.rwd-table')) return;
|
|
this._listFingerprint = fp;
|
|
const alerts = await this.fetchAgenticAlerts();
|
|
root.innerHTML = this.renderShell({ ...metrics, enriched: metrics.enriched }, filtered, alerts);
|
|
this.bindTable(root, filtered);
|
|
this.bindPage(root);
|
|
},
|
|
|
|
async softRefresh() {
|
|
if (!this._pageReady || state.view !== 'tickets') return;
|
|
try {
|
|
await this.loadContext();
|
|
await this.renderListOnly();
|
|
} catch { /* poll */ }
|
|
},
|
|
|
|
async renderPage({ listEl, detailEl, tickets }) {
|
|
this.syncPageTitle();
|
|
await this.loadContext();
|
|
const metrics = this.computeMetrics(tickets);
|
|
state.tickets = metrics.enriched;
|
|
const filtered = this.applyFilters(metrics.enriched);
|
|
this._listFingerprint = this.listFingerprint(filtered);
|
|
const alerts = await this.fetchAgenticAlerts();
|
|
const root = this.mountRoot();
|
|
if (!root) return;
|
|
root.innerHTML = this.renderShell(metrics, filtered, alerts);
|
|
this.bindTable(root, filtered);
|
|
this.bindPage(root);
|
|
this._pageReady = true;
|
|
|
|
if (listEl) listEl.innerHTML = '';
|
|
if (detailEl && !state.selectedTicketId) {
|
|
detailEl.innerHTML = '';
|
|
}
|
|
},
|
|
};
|
|
|
|
window.TicketsWorkspace = TicketsWorkspace;
|