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

1240 lines
46 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.

/**
* Serviços — Orquestração multi-produto (Spec 018)
* Fase 1: clientes derivados VM112 + tiles cPanel + purge Spec 017.
*/
const DeskServices = (() => {
const API_BASE = '/api';
let _domains = [];
let _clients = [];
let _query = '';
let _selectedClientId = null;
let _selectedServiceId = 'email_tenant';
let _billingByDomain = {};
let _loadInFlight = null;
const DOMAINS_CACHE_KEY = 'desk_servicos_domains_v1';
const DOMAINS_CACHE_TTL_MS = 5 * 60 * 1000;
const DOMAINS_FETCH_TIMEOUT_MS = 35000;
const DOMAINS_POLL_TIMEOUT_MS = 22000;
/** Domínios onde purge é proibido (Spec 017 — espelha API VM122). */
const PURGE_BLOCKLIST = new Set(['ligbox.com.br', 'itecnologys.com']);
const LAYER_LABELS = {
tenant: 'Tenant utilizador',
app: 'Aplicação MOSP',
infra: 'Infraestrutura',
};
const OPS_SCOPES = [
{
id: 'carbonio',
label: 'Carbonio',
desc: 'Domínio e contas de e-mail (zmprov)',
layer: 'app',
spec: '017',
specTitle: 'Purge domínio VM112',
action: 'modal',
goto: 'Modal Serviços — contas Carbonio',
},
{
id: 'site',
label: 'ligbox-sites',
desc: 'Pasta do tenant em /opt/ligbox-sites/domains/',
layer: 'tenant',
spec: '017',
specTitle: 'Purge domínio VM112',
action: 'modal',
goto: 'Modal Serviços — pasta site',
},
{
id: 'portal',
label: 'Portal users',
desc: 'Contas Self-Service ligadas ao domínio',
layer: 'tenant',
spec: '028',
specTitle: 'OpenPanel VM123',
action: 'console',
consolePath: '/admin/dominio',
goto: 'Console Domínio',
},
{
id: 'cloudflare',
label: 'Cloudflare',
desc: 'Zona DNS na conta Ibytera',
layer: 'infra',
spec: '037',
specTitle: 'DNS multi-Cloudflare',
action: 'console',
consolePath: '/admin/dominio',
goto: 'Console DNS Viewer',
},
{
id: 'traefik',
label: 'Traefik / SNI',
desc: 'Routers mail.* no CT114',
layer: 'infra',
spec: '026',
specTitle: 'Purge Traefik validation',
action: 'view',
view: 'infra',
goto: 'Desk Infra CODE (CT114)',
},
{
id: 'desk',
label: 'Desk OPS',
desc: 'Webhooks, tickets e audit_domains',
layer: 'app',
spec: '017',
specTitle: 'Purge + histórico Desk',
action: 'view',
view: 'events',
eventsTab: 'purges',
goto: 'Desk Eventos Histórico purges',
},
];
const SERVICE_CATALOG = [
{
id: 'email_tenant',
label: 'E-mail Tenant',
desc: 'Domínio partilhado no Carbonio VM112',
icon: '✉',
wizard: 'vm112-mail',
wizardHost: 'VM112',
deliveryModel: 'saas',
category: 'apps',
layerScope: 'app',
phase: 'active',
},
{
id: 'site_cms',
label: 'Site / CMS',
desc: 'Pasta ligbox-sites do domínio',
icon: '🌐',
wizard: 'vm112-mail',
wizardHost: 'VM112',
deliveryModel: 'saas',
category: 'apps',
layerScope: 'tenant',
phase: 'active',
},
{
id: 'mail_dedicated',
label: 'Servidor E-mail Dedicado',
desc: 'VM ou CT exclusivo — wizard próprio (Proxmox)',
icon: '🖥',
wizard: null,
wizardHost: 'futuro',
deliveryModel: 'saas',
category: 'apps',
layerScope: 'infra',
phase: 'planned',
},
{
id: 'firewall',
label: 'Firewall',
desc: 'pfSense — NAT, regras, WAN',
icon: '🛡',
wizard: 'wizard-iaas-fw',
wizardHost: 'futuro',
deliveryModel: 'iaas',
category: 'security',
layerScope: 'infra',
phase: 'planned',
},
{
id: 'cloud',
label: 'Cloud / IaaS',
desc: 'VPS gerenciado no Proxmox',
icon: '☁',
wizard: 'wizard-iaas-vps',
wizardHost: 'futuro',
deliveryModel: 'iaas',
category: 'infra',
layerScope: 'infra',
phase: 'planned',
},
{
id: 'wazuh_domain',
label: 'Wazuh por domínio',
desc: 'SOC e agentes — wizard próprio',
icon: '👁',
wizard: 'wizard-soc-wazuh',
wizardHost: 'futuro',
deliveryModel: 'saas',
category: 'security',
layerScope: 'app',
phase: 'planned',
},
];
const CATEGORY_LABELS = {
apps: 'Aplicações (MOSP)',
infra: 'Infraestrutura',
security: 'Segurança',
};
const DELIVERY_LABELS = {
saas: 'SaaS',
iaas: 'IaaS',
paas: 'PaaS',
traditional: 'Suporte',
};
function canAccess() {
if (window.DeskModules?.loaded && !window.DeskModules.isEnabled('overview-home')) return false;
return typeof canManageVm112Domains === 'function' && canManageVm112Domains();
}
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function formatFetchError(err) {
const msg = String(err?.message || err || '');
if (err?.name === 'AbortError' || msg.includes('aborted') || msg.includes('Failed to fetch')) {
return 'VM112 não respondeu a tempo — o wizard pode estar sobrecarregado ou a reiniciar. Aguarde 12 min e clique «Tentar de novo».';
}
return msg;
}
async function apiFetch(path, options = {}, timeoutMs = 60000) {
let res;
try {
res = await fetchWithTimeout(`${API_BASE}${path}`, {
headers: authHeaders({ 'Content-Type': 'application/json', ...(options.headers || {}) }),
...options,
}, timeoutMs);
} catch (err) {
throw new Error(formatFetchError(err));
}
if (res.status === 401) { logout(); throw new Error('sessão expirada'); }
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const detail = data.detail;
let errText = typeof detail === 'string' ? detail : JSON.stringify(detail || `${res.status}`);
if (res.status === 504) {
errText = '504 Gateway Timeout — o purge pode demorar vários minutos. Verifique na VM112 se concluiu antes de repetir.';
} else if (res.status === 409) {
errText = typeof detail === 'string'
? detail
: 'Purge já em curso para este domínio — aguarde a conclusão no drawer lateral.';
} else if (res.status === 503) {
errText = typeof detail === 'string'
? detail
: 'Servidor ocupado — aguarde alguns segundos e tente novamente.';
} else if (res.status === 500 && errText === '500') {
errText = 'Erro interno ao iniciar purge — verifique logs da API Desk (VM122).';
}
throw new Error(errText);
}
return res.json();
}
function buildClients(domains) {
return domains.map((d) => ({
id: d.domain,
domain: d.domain,
displayName: d.domain,
subtitle: d.portal_admin_email || 'sem admin portal',
health: d.carbonio_exists ? 'ok' : 'warn',
raw: d,
}));
}
function readDomainsCache() {
try {
const raw = sessionStorage.getItem(DOMAINS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.domains)) return null;
const ageMs = Date.now() - (parsed.at || 0);
return { domains: parsed.domains, ageMs, fresh: ageMs < DOMAINS_CACHE_TTL_MS };
} catch {
return null;
}
}
function writeDomainsCache(domains) {
try {
sessionStorage.setItem(
DOMAINS_CACHE_KEY,
JSON.stringify({ at: Date.now(), domains: domains || [] }),
);
} catch {
/* quota — ignorar */
}
}
function applyDomains(domains) {
_domains = domains || [];
_clients = buildClients(_domains);
if (_selectedClientId && !_clients.some((c) => c.id === _selectedClientId)) {
_selectedClientId = null;
}
}
function hydrateFromCache() {
const cached = readDomainsCache();
if (!cached?.domains?.length) return null;
applyDomains(cached.domains);
return cached;
}
async function loadBillingMap() {
if (!window.DeskModules?.isEnabled('billing-recurrence')) return;
try {
const data = await apiFetch('/v1/billing/accounts?limit=500', {}, 15000);
_billingByDomain = {};
for (const a of data.accounts || []) _billingByDomain[a.domain] = a;
} catch { _billingByDomain = {}; }
}
async function loadDomains(options = {}) {
const timeoutMs = options.timeoutMs ?? DOMAINS_FETCH_TIMEOUT_MS;
if (_loadInFlight) return _loadInFlight;
_loadInFlight = (async () => {
try {
const data = await apiFetch('/v1/vm112/domains', {}, timeoutMs);
applyDomains(data.domains || []);
writeDomainsCache(_domains);
await loadBillingMap();
return _domains;
} finally {
_loadInFlight = null;
}
})();
return _loadInFlight;
}
function filteredClients() {
const q = _query.trim().toLowerCase();
if (!q) return _clients;
return _clients.filter((c) => {
const blob = [
c.domain,
c.subtitle,
c.raw.mail_host,
(c.raw.portal_users || []).map((u) => u.login_id).join(' '),
].join(' ').toLowerCase();
return blob.includes(q);
});
}
function isPurgeBlocked(domain) {
return PURGE_BLOCKLIST.has(String(domain || '').toLowerCase().trim());
}
function selectedClient() {
return _clients.find((c) => c.id === _selectedClientId) || null;
}
function serviceStatus(serviceId, client) {
if (!client) return 'inactive';
const d = client.raw;
if (serviceId === 'email_tenant') {
if (d.carbonio_exists || d.site_folder_exists || (d.portal_users || []).length) return 'active';
return 'inactive';
}
if (serviceId === 'site_cms') {
return d.site_folder_exists ? 'active' : 'inactive';
}
const cat = SERVICE_CATALOG.find((s) => s.id === serviceId);
return cat?.phase === 'planned' ? 'planned' : 'inactive';
}
function statusLabel(status) {
if (status === 'active') return 'Activo';
if (status === 'planned') return 'Em breve';
return 'Não contratado';
}
function activeOfferCount(client) {
return SERVICE_CATALOG.filter((s) => serviceStatus(s.id, client) === 'active').length;
}
function statsHtml() {
const billingActive = Object.values(_billingByDomain).filter((a) => a.recurrence_active).length;
const total = _clients.length;
const emailActive = _clients.filter((c) => serviceStatus('email_tenant', c) === 'active').length;
const sites = _clients.filter((c) => c.raw.site_folder_exists).length;
const logins = _clients.reduce((n, c) => n + (c.raw.portal_users || []).length, 0);
return `
<div class="servicos-stat"><strong>${total}</strong><span>clientes</span></div>
<div class="servicos-stat"><strong>${emailActive}</strong><span>tenant e-mail ativo</span></div>
<div class="servicos-stat"><strong>${sites}</strong><span>tenant site CMS</span></div>
<div class="servicos-stat"><strong>${logins}</strong><span>logins portal</span></div>
<div class="servicos-stat"><strong>${billingActive}</strong><span>recorrências</span></div>`;
}
function clientRow(c) {
const sel = _selectedClientId === c.id ? ' servicos-client-row--selected' : '';
const emailSt = serviceStatus('email_tenant', c);
const offers = activeOfferCount(c);
const protectedDom = isPurgeBlocked(c.domain);
return `
<button type="button" class="servicos-client-row${sel}" data-client-id="${esc(c.id)}">
<span class="servicos-client-dot ${c.health}"></span>
<div class="servicos-client-main">
<strong title="${esc(c.displayName)}">${esc(c.displayName)}</strong>
<span>${esc(c.subtitle)} · ${offers} tenant(s) de oferta</span>
</div>
<div class="servicos-client-badges">
${protectedDom ? '<span class="servicos-client-badge servicos-client-badge--protected" title="Blocklist purge Spec 017 — protegido">🔒</span>' : ''}
<span class="servicos-client-badge servicos-client-badge--${emailSt}">${statusLabel(emailSt)}</span>
${_billingByDomain[c.domain]?.recurrence_active ? `<span class="servicos-billing-badge servicos-billing-badge--active" data-billing-domain="${esc(c.domain)}" title="Recorrência ativa">💳</span>` : (_billingByDomain[c.domain] ? `<span class="servicos-billing-badge" data-billing-domain="${esc(c.domain)}" title="Cobrança">💳</span>` : '')}
</div>
</button>`;
}
function clientsListHtml() {
const rows = filteredClients();
return rows.length
? rows.map(clientRow).join('')
: '<p class="servicos-empty">Nenhum cliente encontrado.</p>';
}
function serviceTile(service, client) {
const status = client ? serviceStatus(service.id, client) : 'inactive';
const clickable = status === 'active' && service.id === 'email_tenant';
const sel = _selectedServiceId === service.id ? ' servicos-tile--selected' : '';
const tag = statusLabel(status);
const dm = DELIVERY_LABELS[service.deliveryModel] || service.deliveryModel;
const wizNote = service.wizard
? `wizard: ${service.wizard} (${service.wizardHost})`
: (service.phase === 'planned' ? 'wizard próprio — planeamento MOSP' : '');
const layerLabel = LAYER_LABELS[service.layerScope] || LAYER_LABELS.tenant;
return `
<article class="servicos-tile servicos-tile--${status}${sel}${clickable ? ' servicos-tile--clickable' : ''}"
data-service-id="${esc(service.id)}"
data-clickable="${clickable ? '1' : '0'}">
<span class="servicos-tile-tier servicos-tile-tier--${esc(service.deliveryModel)}">${esc(dm)}</span>
<span class="servicos-tile-layer servicos-tile-layer--${esc(service.layerScope || 'tenant')}">${esc(layerLabel)}</span>
<div class="servicos-tile-icon" aria-hidden="true">${service.icon}</div>
<h4 class="servicos-tile-title">${esc(service.label)}</h4>
<p class="servicos-tile-desc">${esc(service.desc)}</p>
<span class="servicos-tile-status servicos-tile-status--${status}">${tag}</span>
${wizNote ? `<span class="servicos-tile-wizard">${esc(wizNote)}</span>` : ''}
${clickable ? '<span class="servicos-tile-action">Gerir / Purge →</span>' : ''}
</article>`;
}
function servicesGridHtml() {
const client = selectedClient();
if (!client) {
return '<div class="servicos-empty servicos-empty--center">Seleccione um cliente à esquerda para ver os tenants de oferta.</div>';
}
const byCat = {};
for (const s of SERVICE_CATALOG) {
const cat = s.category || 'apps';
if (!byCat[cat]) byCat[cat] = [];
byCat[cat].push(s);
}
const sections = Object.keys(CATEGORY_LABELS).map((cat) => {
const items = byCat[cat];
if (!items?.length) return '';
return `
<div class="servicos-tiles-section">
<h4 class="servicos-tiles-section-title">${esc(CATEGORY_LABELS[cat])}</h4>
<div class="servicos-tiles-grid">
${items.map((s) => serviceTile(s, client)).join('')}
</div>
</div>`;
}).join('');
return `
<div class="servicos-client-banner">
<strong>${esc(client.displayName)}</strong>
<span>${esc(client.subtitle)} · ${esc(client.raw.mail_host || '')}</span>
</div>
${sections}
<p class="servicos-wizard-note">Cada oferta MOSP terá <strong>wizard próprio</strong> (VM112 = só e-mail). Orquestração aqui no Desk VM122 — sem alterar a Hero do portal.</p>`;
}
function scopeCardHtml(s) {
const layer = LAYER_LABELS[s.layer] || s.layer;
return `
<button type="button" class="servicos-scope-item servicos-scope-item--clickable" data-scope-id="${esc(s.id)}" title="Abrir ${esc(s.goto)}">
<div class="servicos-scope-item-head">
<strong>${esc(s.label)}</strong>
<span class="servicos-scope-layer servicos-scope-layer--${esc(s.layer)}">${esc(layer)}</span>
</div>
<span class="servicos-scope-desc">${esc(s.desc)}</span>
<span class="servicos-scope-meta">
<span class="servicos-scope-spec">Spec ${esc(s.spec)} · ${esc(s.specTitle)}</span>
<span class="servicos-scope-goto">${esc(s.goto)} →</span>
</span>
</button>`;
}
function blocklistBannerHtml(domain) {
if (!isPurgeBlocked(domain)) return '';
return `
<div class="servicos-blocklist-banner" role="note">
<strong>🔒 Blocklist purge (Spec 017)</strong>
<p><code>${esc(domain)}</code> está protegido — purge desactivado. Domínios fixos: ${[...PURGE_BLOCKLIST].map((d) => `<code>${esc(d)}</code>`).join(', ')}.</p>
</div>`;
}
function scopesHtml() {
const client = selectedClient();
if (!client) {
return '<p class="servicos-scope-hint">Escolha um cliente e o serviço <strong>E-mail Tenant</strong> para ver o escopo de purge OPS.</p>';
}
if (_selectedServiceId !== 'email_tenant') {
return `<p class="servicos-scope-hint">Escopo OPS detalhado disponível para <strong>E-mail Tenant</strong>. Serviço actual: ${esc(SERVICE_CATALOG.find((s) => s.id === _selectedServiceId)?.label || _selectedServiceId)}.</p>`;
}
return `${blocklistBannerHtml(client.domain)}${OPS_SCOPES.map(scopeCardHtml).join('')}`;
}
function pageHtml() {
return `
<div class="servicos-page">
<header class="servicos-header">
<div>
<h2 class="servicos-title">Orquestração de Serviços</h2>
<p class="servicos-sub">Desk VM122 · Orquestração MOSP</p>
<p class="servicos-lead">Clientes e <strong>tenants de oferta</strong> — cada produto com wizard próprio; aqui gere estado OPS e purge de teste.</p>
</div>
<button type="button" class="btn btn-ghost" id="servicos-refresh">Actualizar</button>
</header>
<div class="servicos-stats-row" id="servicos-stats">${statsHtml()}</div>
<div class="servicos-layout">
<section class="servicos-panel servicos-panel--clients">
<div class="servicos-panel-head">
<h3>Clientes <span class="servicos-count" id="servicos-client-count">${filteredClients().length}</span></h3>
<input type="search" id="servicos-search" class="servicos-search" placeholder="Pesquisar cliente…" value="${esc(_query)}"/>
</div>
<div class="servicos-panel-body" id="servicos-client-list">${clientsListHtml()}</div>
</section>
<section class="servicos-panel servicos-panel--services">
<div class="servicos-panel-head"><h3>Tenants de Oferta</h3></div>
<div class="servicos-panel-body" id="servicos-services">${servicesGridHtml()}</div>
</section>
<aside class="servicos-panel servicos-panel--scopes">
<div class="servicos-panel-head"><h3>Escopo OPS</h3></div>
<div class="servicos-panel-body servicos-scopes" id="servicos-scopes">${scopesHtml()}</div>
<p class="servicos-scope-note">Purge (Spec 017): tile E-mail Tenant → modal → senha Root + confirmação do domínio.</p>
</aside>
</div>
</div>`;
}
function refreshPanels(root) {
const list = root.querySelector('#servicos-client-list');
const count = root.querySelector('#servicos-client-count');
const services = root.querySelector('#servicos-services');
const scopes = root.querySelector('#servicos-scopes');
const stats = root.querySelector('#servicos-stats');
if (list) list.innerHTML = clientsListHtml();
if (count) count.textContent = String(filteredClients().length);
if (services) services.innerHTML = servicesGridHtml();
if (scopes) scopes.innerHTML = scopesHtml();
if (stats) stats.innerHTML = statsHtml();
bindClientClicks(root);
bindServiceClicks(root);
bindScopeClicks(root);
}
function bindPage(root) {
root.querySelector('#servicos-refresh')?.addEventListener('click', async () => {
root.querySelector('#servicos-services').innerHTML = '<p class="loading">A actualizar…</p>';
await loadDomains();
refreshPanels(root);
});
root.querySelector('#servicos-search')?.addEventListener('input', (e) => {
_query = e.target.value;
refreshPanels(root);
});
bindClientClicks(root);
bindServiceClicks(root);
bindScopeClicks(root);
}
function bindClientClicks(root) {
root.querySelectorAll('[data-client-id]').forEach((btn) => {
btn.addEventListener('click', () => {
_selectedClientId = btn.dataset.clientId;
_selectedServiceId = 'email_tenant';
refreshPanels(root);
});
});
}
function bindScopeClicks(root) {
root.querySelectorAll('[data-scope-id]').forEach((btn) => {
btn.addEventListener('click', () => navigateScope(btn.dataset.scopeId));
});
}
function navigateScope(scopeId) {
const scope = OPS_SCOPES.find((s) => s.id === scopeId);
const client = selectedClient();
if (!scope || !client) return;
const domain = client.domain;
if (scope.action === 'modal') {
openModal(domain);
return;
}
if (scope.action === 'console') {
if (typeof openConsoleDnsViewer === 'function' && scope.id === 'cloudflare') {
openConsoleDnsViewer(domain);
return;
}
if (typeof openConsolePath === 'function') {
openConsolePath(scope.consolePath || '/admin/dominio', { domain });
}
return;
}
if (scope.action === 'view' && window.DeskNavigate?.go) {
window.DeskNavigate.go(scope.view, {
eventsTab: scope.eventsTab,
ticketFilter: scope.ticketFilter,
});
} else if (scope.action === 'view') {
document.querySelector(`[data-view="${scope.view}"]`)?.click();
}
}
function bindServiceClicks(root) {
root.querySelectorAll('[data-service-id]').forEach((tile) => {
tile.addEventListener('click', () => {
_selectedServiceId = tile.dataset.serviceId;
const client = selectedClient();
if (tile.dataset.clickable === '1' && client) {
openModal(client.domain);
}
refreshPanels(root);
});
});
}
function formatTs(iso) {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return String(iso);
}
}
const PURGE_WAIT_STEPS = [
...OPS_SCOPES.map((s) => s.label),
'Desk — webhook_events',
'Desk — tickets',
'Desk — audit_domains',
'Desk — assist_sessions',
'Desk — audit_checks',
'Purge concluído',
];
let _purgeElapsedTimer = null;
let _purgeLiveSteps = [];
function upsertPurgeStep(step) {
if (!step) return;
const runVm112 = _purgeLiveSteps.findIndex(
(s) => s.status === 'running' && String(s.label).includes('VM112')
);
if (runVm112 >= 0 && step.status === 'ok' && String(step.label).includes('VM112')) {
_purgeLiveSteps.splice(runVm112, 1);
}
const sameLabel = _purgeLiveSteps.findIndex((s) => s.label === step.label);
if (sameLabel >= 0) {
_purgeLiveSteps[sameLabel] = step;
} else {
_purgeLiveSteps.push(step);
}
renderPurgeTimeline(_purgeLiveSteps, {
running: _purgeLiveSteps.some((s) => s.status === 'running'),
});
}
function onPurgeHeartbeat(ev) {
const idx = _purgeLiveSteps.findIndex(
(s) => s.status === 'running' && String(s.label).includes('VM112')
);
if (idx < 0) return;
_purgeLiveSteps[idx] = {
..._purgeLiveSteps[idx],
detail: `Em curso na VM112 (${ev.elapsed || 0}s)`,
};
renderPurgeTimeline(_purgeLiveSteps, { running: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isNetworkFetchError(err) {
const msg = String(err?.message || err || '');
return msg === 'Failed to fetch'
|| err?.name === 'AbortError'
|| msg.includes('NetworkError')
|| msg.includes('network');
}
async function recoverPurgeJob(domain, jobId) {
const q = domain ? `?domain=${encodeURIComponent(domain)}` : '';
return apiFetch(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}/recover${q}`, {
method: 'POST',
body: '{}',
}, 60000);
}
function applyPurgeJobToTimeline(job) {
if (!job) return;
const steps = Array.isArray(job.timeline) ? job.timeline : [];
if (!steps.length) {
if (job.status === 'running') {
upsertPurgeStep({
at: new Date().toISOString(),
label: 'Purge em execução',
status: 'running',
detail: 'A aguardar passos da VM112…',
});
}
return;
}
for (const step of steps) upsertPurgeStep(step);
renderPurgeTimeline(_purgeLiveSteps, { running: job.status === 'running' });
}
async function showPurgeSuccess(done, domain) {
applyPurgeJobToTimeline(done);
const deskTotal = Object.values(done?.desk || {}).reduce((a, b) => a + Number(b || 0), 0);
upsertPurgeStep({
at: new Date().toISOString(),
label: 'Purge concluído',
status: 'ok',
detail: deskTotal ? `Desk: ${deskTotal} registo(s) removido(s)` : (done?.recovered ? 'Recuperação automática' : 'Concluído'),
});
renderPurgeTimeline(_purgeLiveSteps, { running: false });
const msg = document.getElementById('vm112-purge-msg');
const btn = document.getElementById('vm112-purge-btn');
if (msg) {
msg.textContent = `✓ Purge concluído com sucesso.${deskTotal ? ` (${deskTotal} registo(s) Desk)` : ''}`;
msg.classList.add('vm112-purge-success');
}
if (btn) { btn.textContent = 'Concluído ✓'; btn.disabled = true; }
_domains = _domains.filter((d) => d.domain !== domain);
await loadBillingMap();
_clients = buildClients(_domains);
if (_selectedClientId === domain) _selectedClientId = null;
setTimeout(() => {
const el = document.getElementById('overview-home-content');
if (el) renderPage(el);
closeModal();
}, 8000);
}
async function tryRecoverPurge(domain, jobId) {
try {
const job = await recoverPurgeJob(domain, jobId);
applyPurgeJobToTimeline(job);
return job?.status === 'done' ? job : null;
} catch {
return null;
}
}
async function pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode = '') {
const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, {
method: 'POST',
body: JSON.stringify({
confirm_domain: confirmDomain,
root_password: rootPassword,
purge_auth_code: purgeAuthCode || '',
}),
}, 60000);
const jobId = start.job_id;
if (!jobId) throw new Error('Job purge não iniciado');
_lastPurgeJobId = jobId;
let networkErrors = 0;
while (true) {
let job;
try {
job = await apiFetch(`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}`, {}, 60000);
networkErrors = 0;
} catch (e) {
const msg = String(e.message || '');
if (msg.includes('não encontrado') || msg.includes('404') || msg === '500' || msg.includes('502') || msg.includes('503')) {
const recovered = await tryRecoverPurge(domain, jobId);
if (recovered) return recovered;
}
if (isNetworkFetchError(e)) {
networkErrors += 1;
upsertPurgeStep({
at: new Date().toISOString(),
label: 'Ligação ao servidor',
status: 'running',
detail: `Reconectando… (tentativa ${networkErrors})`,
});
if (networkErrors >= 2) {
const recovered = await tryRecoverPurge(domain, jobId);
if (recovered) return recovered;
}
await sleep(2500);
continue;
}
throw e;
}
applyPurgeJobToTimeline(job);
if (job.elapsed_vm112) {
const el = document.getElementById('vm112-purge-elapsed');
if (el) el.textContent = `${job.elapsed_vm112}s`;
}
if (job.status === 'done') {
return job;
}
if (job.status === 'error') {
throw new Error(job.error || job.timeline?.find((s) => s.status === 'fail')?.detail || 'Purge falhou');
}
await sleep(2000);
}
}
async function consumePurgeStream(domain, confirmDomain, rootPassword) {
const res = await fetchWithTimeout(
`${API_BASE}/v1/vm112/domains/${encodeURIComponent(domain)}/purge/stream`,
{
method: 'POST',
headers: authHeaders({
'Content-Type': 'application/json',
Accept: 'text/event-stream',
}),
body: JSON.stringify({
confirm_domain: confirmDomain,
root_password: rootPassword,
}),
},
600000
);
if (res.status === 401) { logout(); throw new Error('sessão expirada'); }
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const detail = data.detail;
throw new Error(typeof detail === 'string' ? detail : `HTTP ${res.status}`);
}
const reader = res.body?.getReader();
if (!reader) throw new Error('Stream indisponível no browser');
const decoder = new TextDecoder();
let buf = '';
let donePayload = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const line = part.split('\n').find((l) => l.startsWith('data: '));
if (!line) continue;
let ev;
try {
ev = JSON.parse(line.slice(6));
} catch {
continue;
}
if (ev.type === 'step') upsertPurgeStep(ev.step);
else if (ev.type === 'heartbeat') onPurgeHeartbeat(ev);
else if (ev.type === 'error') {
upsertPurgeStep(ev.step || { at: new Date().toISOString(), label: 'Purge falhou', status: 'fail', detail: 'erro' });
throw new Error(ev.step?.detail || 'Purge falhou');
} else if (ev.type === 'done') {
upsertPurgeStep(ev.step);
donePayload = ev;
}
}
}
return donePayload;
}
function stopPurgeElapsed() {
if (_purgeElapsedTimer) {
clearInterval(_purgeElapsedTimer);
_purgeElapsedTimer = null;
}
}
function openPurgeDrawer() {
const shell = document.getElementById('vm112-modal-shell');
const drawer = document.getElementById('vm112-purge-drawer');
if (shell) shell.classList.add('vm112-modal-shell--purge-open');
if (drawer) drawer.setAttribute('aria-hidden', 'false');
}
function closePurgeDrawer() {
stopPurgeElapsed();
const shell = document.getElementById('vm112-modal-shell');
const drawer = document.getElementById('vm112-purge-drawer');
const list = document.getElementById('vm112-purge-timeline');
const elapsed = document.getElementById('vm112-purge-elapsed');
if (shell) shell.classList.remove('vm112-modal-shell--purge-open');
if (drawer) drawer.setAttribute('aria-hidden', 'true');
if (list) list.innerHTML = '';
if (elapsed) elapsed.textContent = '—';
}
function renderPurgeTimeline(steps, { running = false } = {}) {
const list = document.getElementById('vm112-purge-timeline');
if (!list) return;
list.innerHTML = (steps || []).map((step, i) => {
const status = step.status || 'pending';
const isRun = running && status === 'running';
return `
<li class="vm112-purge-step vm112-purge-step--${esc(status)}${isRun ? ' vm112-purge-step--pulse' : ''}">
<span class="vm112-purge-step-time">${esc(formatTs(step.at))}</span>
<div class="vm112-purge-step-body">
<strong>${esc(step.label)}</strong>
${step.detail ? `<span>${esc(step.detail)}</span>` : ''}
</div>
</li>`;
}).join('');
list.scrollTop = list.scrollHeight;
}
function startPurgeElapsed() {
const el = document.getElementById('vm112-purge-elapsed');
const t0 = Date.now();
stopPurgeElapsed();
const tick = () => {
const sec = Math.floor((Date.now() - t0) / 1000);
if (el) el.textContent = `${sec}s`;
};
tick();
_purgeElapsedTimer = setInterval(tick, 1000);
}
function initPurgeTimelineRunning() {
_purgeLiveSteps = [];
_lastPurgeJobId = null;
openPurgeDrawer();
startPurgeElapsed();
upsertPurgeStep({
at: new Date().toISOString(),
label: 'A iniciar purge',
status: 'running',
detail: 'A validar credenciais…',
});
}
async function runPurge(domain) {
const msg = document.getElementById('vm112-purge-msg');
const confirmDomain = document.getElementById('vm112-purge-confirm')?.value?.trim() || '';
const rootPassword = document.getElementById('vm112-purge-root-pwd')?.value || '';
const purgeAuthCode = document.getElementById('vm112-purge-auth-code')?.value?.trim() || '';
const needsAuthCode = Boolean(document.getElementById('vm112-purge-auth-code'));
if (!confirmDomain || !rootPassword) {
if (msg) msg.textContent = 'Preencha domínio e senha Root.';
return;
}
if (needsAuthCode && !purgeAuthCode) {
if (msg) msg.textContent = 'Este domínio exige código de autorização — gere em Infra (root).';
return;
}
if (!window.confirm(`PURGE definitivo de ${domain}?`)) return;
const btn = document.getElementById('vm112-purge-btn');
if (btn) { btn.disabled = true; btn.textContent = 'A apagar…'; }
if (msg) { msg.textContent = 'A executar purge…'; msg.classList.remove('vm112-purge-success'); }
initPurgeTimelineRunning();
try {
const done = await pollPurgeJob(domain, confirmDomain, rootPassword, purgeAuthCode);
stopPurgeElapsed();
showPurgeSuccess(done, domain);
return;
} catch (e) {
stopPurgeElapsed();
if (isNetworkFetchError(e) && _purgeLiveSteps.some((s) => s.status === 'ok')) {
const recovered = _lastPurgeJobId
? await tryRecoverPurge(domain, _lastPurgeJobId).catch(() => null)
: null;
if (recovered) {
showPurgeSuccess(recovered, domain);
return;
}
showPurgeSuccess({ status: 'done', desk: {}, timeline: _purgeLiveSteps, recovered: true }, domain);
return;
}
if (!_purgeLiveSteps.length) {
const failAt = new Date().toISOString();
renderPurgeTimeline([
{ at: failAt, label: 'Purge falhou', status: 'fail', detail: e.message || 'erro' },
]);
}
const errMsg = isNetworkFetchError(e)
? 'Ligação interrompida durante o purge — verifique se o domínio já saiu da lista'
: (e.message || 'Purge falhou — verifique se o domínio já foi removido na lista');
if (msg) msg.textContent = errMsg;
if (btn) { btn.disabled = false; btn.textContent = 'Apagar domínio e todos os dados'; }
}
}
function closeModal() {
stopVm112LoadStepAnimation();
closePurgeDrawer();
const modal = document.getElementById('vm112-domain-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function renderModalBody(d, domain) {
const accN = (d.accounts || []).length;
const steps = (d.infra_status?.steps || [])
.map((s) => `<li class="${s.ok ? 'ok' : 'fail'}"><strong>${esc(s.label)}</strong> — ${esc(s.message)}</li>`)
.join('');
const accounts = (d.accounts || [])
.map((a) => `<li>${esc(typeof a === 'string' ? a : a.email || a)}</li>`).join('')
|| '<li class="muted">Nenhuma</li>';
const cf = d.cloudflare_zone;
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : (cf?.error ? `Erro CF: ${cf.error}` : 'Sem zona Cloudflare Ibytera');
const extraAuth = Boolean(d.purge_extra_auth_required);
const purgeBlocked = Boolean(d.purge_blocked);
const authCodeField = extraAuth
? `
<p class="vm112-purge-warn">Domínio protegido — exige <strong>código de autorização</strong> gerado em Infra (root).</p>
<label>Código autorização purge</label>
<input type="text" id="vm112-purge-auth-code" class="vm112-purge-input" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"/>
`
: '';
const purgeBtn = purgeBlocked
? '<p class="vm112-purge-warn">Domínio na blocklist — purge desactivado.</p>'
: `<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>`;
return `
<div class="modal-section">
<h4>Serviço: E-mail Tenant</h4>
<p><strong>Cliente / domínio:</strong> ${esc(domain)}</p>
<p><strong>Admin portal:</strong> ${esc(d.portal_admin_email || '—')}</p>
<p><strong>Carbonio:</strong> ${d.carbonio_exists ? 'ativo' : 'ausente'}</p>
<p><strong>Pasta site:</strong> ${d.site_folder_exists ? 'sim' : 'não'}</p>
<p><strong>Cloudflare:</strong> ${esc(cfTxt)}</p>
</div>
<div class="modal-section">
<h4>Infraestrutura VM112</h4>
<ul class="vm112-infra-steps">${steps || '<li>—</li>'}</ul>
</div>
<div class="modal-section">
<h4>Contas Carbonio (${accN})</h4>
<ul>${accounts}</ul>
</div>
<div class="modal-section vm112-purge-zone">
<h4>Purge completo (testes — Spec 017)</h4>
<p class="vm112-purge-warn">Remove Carbonio, site, portal users, Cloudflare, Traefik e registos Desk. <strong>Irreversível.</strong> Requer senha Root.</p>
<label>Confirmar domínio</label>
<input type="text" id="vm112-purge-confirm" class="vm112-purge-input" placeholder="${esc(domain)}" autocomplete="off"/>
<label>Senha Root</label>
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
${authCodeField}
${purgeBtn}
<p id="vm112-purge-msg" class="ticket-meta"></p>
</div>`;
}
function vm112DetailLoadingPanelHtml() {
return `
<div class="vm112-load-panel" role="status" aria-live="polite" aria-busy="true">
<div class="vm112-load-progress" aria-hidden="true"><span class="vm112-load-progress-bar"></span></div>
<p class="vm112-load-title">A consultar VM112<span class="vm112-load-dots"></span></p>
<ul class="vm112-load-steps" id="vm112-load-steps">
<li class="vm112-load-step vm112-load-step--active" data-load-step="0">Carbonio — contas (zmprov)</li>
<li class="vm112-load-step" data-load-step="1">Infra CT114 — SNI, Traefik, HTTPS</li>
<li class="vm112-load-step" data-load-step="2">Cloudflare — zona DNS</li>
</ul>
<p class="ticket-meta">1.ª consulta ~1015s · reabrir o mesmo domínio usa cache.</p>
</div>`;
}
function vm112LoadingButtonHtml(label = 'A carregar') {
return `<button type="button" class="btn btn-danger btn-loading" disabled aria-busy="true">
<span class="vm112-load-spinner" aria-hidden="true"></span>
<span>${esc(label)}<span class="vm112-load-dots"></span></span>
</button>`;
}
let _vm112LoadStepTimer = null;
function startVm112LoadStepAnimation(root) {
stopVm112LoadStepAnimation();
const steps = root?.querySelectorAll('#vm112-load-steps .vm112-load-step');
if (!steps?.length) return;
let idx = 0;
_vm112LoadStepTimer = setInterval(() => {
steps.forEach((el, i) => {
el.classList.toggle('vm112-load-step--active', i === idx);
});
idx = (idx + 1) % steps.length;
}, 2800);
}
function stopVm112LoadStepAnimation() {
if (_vm112LoadStepTimer) {
clearInterval(_vm112LoadStepTimer);
_vm112LoadStepTimer = null;
}
}
function setModalSubLoading() {
const sub = document.getElementById('vm112-domain-modal-sub');
if (!sub) return;
sub.innerHTML = 'E-mail Tenant · <span class="vm112-modal-sub-loading">a carregar<span class="vm112-load-dots"></span></span>';
}
function modalSkeletonHtml(raw, domain) {
return `
<div class="modal-section">
<h4>Serviço: E-mail Tenant</h4>
<p><strong>Cliente / domínio:</strong> ${esc(domain)}</p>
<p><strong>Admin portal:</strong> ${esc(raw?.portal_admin_email || '—')}</p>
<p><strong>Carbonio:</strong> ${raw?.carbonio_exists ? 'ativo' : 'ausente'}</p>
<p><strong>Pasta site:</strong> ${raw?.site_folder_exists ? 'sim' : 'não'}</p>
</div>
<div class="modal-section">
${vm112DetailLoadingPanelHtml()}
</div>
<div class="modal-section vm112-purge-zone">
<h4>Purge completo (testes — Spec 017)</h4>
<p class="ticket-meta">Formulário disponível após carregar detalhe.</p>
${vm112LoadingButtonHtml('A carregar')}
</div>`;
}
function openModal(domain) {
const modal = document.getElementById('vm112-domain-modal');
const body = document.getElementById('vm112-domain-modal-body');
const title = document.getElementById('vm112-domain-modal-title');
if (!modal || !body) return;
stopVm112LoadStepAnimation();
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
title.textContent = domain;
setModalSubLoading();
const raw = selectedClient()?.raw;
body.innerHTML = raw
? modalSkeletonHtml(raw, domain)
: `<div class="modal-section">${vm112DetailLoadingPanelHtml()}</div>`;
body.setAttribute('aria-busy', 'true');
startVm112LoadStepAnimation(body);
loadModal(domain);
modal.querySelectorAll('[data-close-vm112-modal]').forEach((el) => {
el.onclick = closeModal;
});
}
async function loadModal(domain) {
const body = document.getElementById('vm112-domain-modal-body');
const sub = document.getElementById('vm112-domain-modal-sub');
try {
const d = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}`, {}, 45000);
stopVm112LoadStepAnimation();
body.removeAttribute('aria-busy');
const accN = (d.accounts || []).length;
if (sub) sub.textContent = `E-mail Tenant · ${accN} conta(s) · ${d.mail_host || ''}`;
body.innerHTML = renderModalBody(d, domain);
if (!d.purge_blocked) {
body.querySelector('#vm112-purge-btn')?.addEventListener('click', () => runPurge(domain));
}
} catch (e) {
stopVm112LoadStepAnimation();
body.removeAttribute('aria-busy');
if (sub) sub.textContent = 'E-mail Tenant · erro ao carregar';
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p><button type="button" class="btn btn-sm" id="servicos-modal-retry">Tentar de novo</button>`;
body.querySelector('#servicos-modal-retry')?.addEventListener('click', () => {
const rawRetry = selectedClient()?.raw;
setModalSubLoading();
body.innerHTML = rawRetry
? modalSkeletonHtml(rawRetry, domain)
: `<div class="modal-section">${vm112DetailLoadingPanelHtml()}</div>`;
body.setAttribute('aria-busy', 'true');
startVm112LoadStepAnimation(body);
loadModal(domain);
});
}
}
function setPollStatus(root, message, isError = false) {
if (!root) return;
let el = root.querySelector('#servicos-poll-status');
if (!message) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('p');
el.id = 'servicos-poll-status';
el.className = 'servicos-poll-status';
root.querySelector('.servicos-header')?.appendChild(el);
}
el.className = `servicos-poll-status${isError ? ' servicos-poll-status--err' : ''}`;
el.textContent = message;
}
function renderPageShell(container, { staleHint = '' } = {}) {
if (_clients.length && !_selectedClientId) {
_selectedClientId = _clients[0].id;
}
container.innerHTML = pageHtml();
bindPage(container);
if (staleHint) setPollStatus(container, staleHint);
}
async function renderPage(container, options = {}) {
const { poll = false } = options;
if (!container) return;
if (!canAccess()) {
container.innerHTML = '<p class="loading">Sem permissão — perfil Admin (super_admin ou ops_lead) necessário.</p>';
return;
}
const hasPage = Boolean(container.querySelector('.servicos-page'));
const cached = readDomainsCache();
if (poll && hasPage) {
try {
await loadDomains({ timeoutMs: DOMAINS_POLL_TIMEOUT_MS });
refreshPanels(container);
setPollStatus(container, '');
} catch (e) {
setPollStatus(container, `Actualização falhou (lista anterior mantida): ${e.message}`, true);
}
return;
}
if (!hasPage) {
const hydrated = hydrateFromCache();
renderPageShell(container, {
staleHint: hydrated
? (hydrated.fresh ? 'A actualizar lista VM112…' : 'Lista em cache — a actualizar VM112…')
: 'A carregar clientes VM112…',
});
}
try {
await loadDomains({ timeoutMs: DOMAINS_FETCH_TIMEOUT_MS });
if (hasPage) {
refreshPanels(container);
setPollStatus(container, '');
} else {
renderPageShell(container);
setPollStatus(container, '');
}
} catch (e) {
if (hasPage || cached?.domains?.length) {
setPollStatus(container, `VM112 lento ou API Desk a reiniciar — ${e.message}`, true);
return;
}
container.innerHTML = `<div class="servicos-page"><p class="servicos-empty">Erro ao carregar VM112: ${esc(e.message)}</p><p class="ticket-meta">A VM112 demora ~1015s por pedido. Se acabámos de actualizar a API Desk, aguarde 30s e tente de novo.</p><button type="button" class="btn" id="servicos-retry">Tentar de novo</button></div>`;
container.querySelector('#servicos-retry')?.addEventListener('click', () => renderPage(container));
}
}
return { renderPage, loadDomains, openModal, canAccess, SERVICE_CATALOG };
})();
window.DeskServices = DeskServices;
window.DeskAccounts = DeskServices;