ligbox-ops-platform/projects/ops-desk/frontend/assets/tickets-sla.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

162 lines
5.5 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.

/**
* Tickets SLA + Priority Score v0 — port do ResultsDesk (React)
* Spec negócio Roger: SLA_SETTINGS + fórmula composta origem × idle
*/
const TicketsSla = {
SETTINGS: {
ONBOARD: { response: 2, resolution: 72 },
DESK: { response: 1, resolution: 8 },
SECURITY: { response: 0.5, resolution: 4 },
AGENTIC_OPS: { response: 0.5, resolution: 4 },
},
PRIORITY_CRITICAL: 7,
PRIORITY_HIGH: 4,
PRIORITY_WARN: 2,
mapTenant(t) {
const src = t.source || '';
const ev = t.event || '';
if (src === 'wazuh' || ev === 'wazuh.alert' || src === 'vm112-security' || (t.subject || '').startsWith('[security]')) {
return 'SECURITY';
}
if (src === 'vm112-onboard' || ev.startsWith('onboarding') || ev.startsWith('domain.') || ev.startsWith('dns.')) {
return 'ONBOARD';
}
if (src === 'agentic-ops' || (t.subject || '').toLowerCase().includes('[agentic')) {
return 'AGENTIC_OPS';
}
return 'DESK';
},
hoursSince(iso) {
if (!iso) return 0;
try {
return Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 3600000));
} catch {
return 0;
}
},
isInternalDomain(domain) {
const d = String(domain || '').toLowerCase();
return d.includes('itecnologys.com') || d.includes('ligbox.com.br');
},
computePriority(t) {
const tenant = this.mapTenant(t);
const sla = this.SETTINGS[tenant] || { response: 2, resolution: 24 };
const totalAgeHours = this.hoursSince(t.created_at);
const lastEvent = t._lastEventAt || null;
const idleHours = t._idleHours != null
? t._idleHours
: this.hoursSince(lastEvent || t.created_at);
const wazuhLevel = t.severity != null ? Number(t.severity) : null;
const isLead = t.crm_track === 'lead' || t.is_lead;
let originScore = 1.0;
if (tenant === 'SECURITY' && wazuhLevel != null && wazuhLevel >= 1) originScore = 4.0;
else if (this.isInternalDomain(t.domain || t.email)) originScore = 3.0;
else if (isLead) originScore = 2.0;
let idleScore = 0.5;
if (idleHours >= sla.resolution * 2) idleScore = 2.5;
else if (idleHours >= sla.resolution) idleScore = 2.0;
else if (idleHours >= sla.response * 2) idleScore = 1.5;
else if (idleHours >= sla.response) idleScore = 1.0;
const priorityScore = parseFloat((originScore * idleScore).toFixed(1));
let responseSla = 'OK';
let resolutionSla = 'OK';
if (idleHours > sla.response) responseSla = 'WARNING';
if (idleHours > sla.resolution) resolutionSla = `STALE (${sla.resolution}h+)`;
let priorityBand = 'info';
if (priorityScore >= this.PRIORITY_CRITICAL) priorityBand = 'critical';
else if (priorityScore >= this.PRIORITY_HIGH) priorityBand = 'high';
else if (priorityScore >= this.PRIORITY_WARN) priorityBand = 'warn';
return {
...t,
_tenant: tenant,
_priorityScore: priorityScore,
_priorityBand: priorityBand,
_totalAgeHours: totalAgeHours,
_idleHours: idleHours,
_responseSla: responseSla,
_resolutionSla: resolutionSla,
_slaLimits: sla,
};
},
processList(tickets) {
return tickets
.map((t) => this.computePriority(t))
.sort((a, b) => b._priorityScore - a._priorityScore);
},
computeOverview(tickets) {
const rows = tickets.map((t) => this.computePriority(t));
const active = rows.filter((t) => !['closed'].includes(t.status));
const resolved = rows.filter((t) => ['resolved', 'closed'].includes(t.status));
const slaOk = active.filter((t) => t._responseSla === 'OK' && t._resolutionSla === 'OK').length;
const slaPct = active.length ? Math.round((slaOk / active.length) * 100) : 100;
const mttrSamples = resolved.map((t) => t._totalAgeHours).filter((h) => h > 0);
const mttr = mttrSamples.length
? (mttrSamples.reduce((a, b) => a + b, 0) / mttrSamples.length).toFixed(1)
: '—';
const responseSamples = rows
.filter((t) => t.assigned_at || t.assisted_at)
.map((t) => {
try {
const start = new Date(t.created_at).getTime();
const end = new Date(t.assigned_at || t.assisted_at).getTime();
return Math.max(0, Math.round((end - start) / 3600000));
} catch {
return null;
}
})
.filter((h) => h != null);
const avgResponse = responseSamples.length
? (responseSamples.reduce((a, b) => a + b, 0) / responseSamples.length).toFixed(1)
: (active.length
? (active.reduce((s, t) => s + t._idleHours, 0) / active.length).toFixed(1)
: '—');
const buckets = { critical: 0, high: 0, warn: 0, info: 0 };
for (const t of rows) buckets[t._priorityBand] = (buckets[t._priorityBand] || 0) + 1;
const criticalWaiting = active.filter(
(t) => t._priorityBand === 'critical' && ['open', 'escalated', 'assisting'].includes(t.status),
).length;
return {
slaPct,
mttr,
avgResponse,
buckets,
criticalWaiting,
total: rows.length,
};
},
getInitials(name) {
if (!name) return '—';
const parts = String(name).trim().split(/\s+/);
return ((parts[0]?.[0] || '') + (parts[1]?.[0] || parts[0]?.[1] || '')).toUpperCase() || '—';
},
assigneeLabel(t, userMap = {}) {
const raw = t.assisted_by || t.assigned_to || '';
if (!raw) return { label: 'Sem atribuição', initials: '—' };
const label = userMap[raw] || raw.replace(/[._]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return { label, initials: this.getInitials(label) };
},
};
window.TicketsSla = TicketsSla;