ligbox-ops-platform/projects/ops-desk/frontend/assets/tickets-workspace.js
Ligbox Spec Hub b03bb2c37c feat(desk): UI/API Spec 039-041 + deploy atómico e smoke GREEN
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>
2026-07-02 13:32:15 +00:00

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 &amp; 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;