897 lines
33 KiB
JavaScript
897 lines
33 KiB
JavaScript
/**
|
||
* 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 = {};
|
||
|
||
const OPS_SCOPES = [
|
||
{ id: 'carbonio', label: 'Carbonio', desc: 'Domínio e contas de e-mail (zmprov)' },
|
||
{ id: 'site', label: 'ligbox-sites', desc: 'Pasta do tenant em /opt/ligbox-sites/domains/' },
|
||
{ id: 'portal', label: 'Portal users', desc: 'Contas Self-Service ligadas ao domínio' },
|
||
{ id: 'cloudflare', label: 'Cloudflare', desc: 'Zona DNS na conta Ibytera' },
|
||
{ id: 'traefik', label: 'Traefik / SNI', desc: 'Routers mail.* no CT114' },
|
||
{ id: 'desk', label: 'Desk OPS', desc: 'Webhooks, tickets e audit_domains' },
|
||
];
|
||
|
||
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',
|
||
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',
|
||
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',
|
||
phase: 'planned',
|
||
},
|
||
{
|
||
id: 'firewall',
|
||
label: 'Firewall',
|
||
desc: 'pfSense — NAT, regras, WAN',
|
||
icon: '🛡',
|
||
wizard: 'wizard-iaas-fw',
|
||
wizardHost: 'futuro',
|
||
deliveryModel: 'iaas',
|
||
category: 'security',
|
||
phase: 'planned',
|
||
},
|
||
{
|
||
id: 'cloud',
|
||
label: 'Cloud / IaaS',
|
||
desc: 'VPS gerenciado no Proxmox',
|
||
icon: '☁',
|
||
wizard: 'wizard-iaas-vps',
|
||
wizardHost: 'futuro',
|
||
deliveryModel: 'iaas',
|
||
category: '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',
|
||
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
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 1–2 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.';
|
||
}
|
||
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,
|
||
}));
|
||
}
|
||
|
||
async function loadBillingMap() {
|
||
if (!window.DeskModules?.isEnabled('billing-recurrence')) return;
|
||
try {
|
||
const data = await apiFetch('/v1/billing/accounts?limit=500');
|
||
_billingByDomain = {};
|
||
for (const a of data.accounts || []) _billingByDomain[a.domain] = a;
|
||
} catch { _billingByDomain = {}; }
|
||
}
|
||
|
||
async function loadDomains() {
|
||
const data = await apiFetch('/v1/vm112/domains', {}, 120000);
|
||
_domains = data.domains || [];
|
||
await loadBillingMap();
|
||
_clients = buildClients(_domains);
|
||
if (_selectedClientId && !_clients.some((c) => c.id === _selectedClientId)) {
|
||
_selectedClientId = null;
|
||
}
|
||
return _domains;
|
||
}
|
||
|
||
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 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);
|
||
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>${esc(c.displayName)}</strong>
|
||
<span>${esc(c.subtitle)} · ${offers} tenant(s) de oferta</span>
|
||
</div>
|
||
<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>` : '')}
|
||
</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' : '');
|
||
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>
|
||
<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 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 OPS_SCOPES.map((s) => `
|
||
<div class="servicos-scope-item">
|
||
<strong>${esc(s.label)}</strong>
|
||
<span>${esc(s.desc)}</span>
|
||
</div>`).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);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function bindClientClicks(root) {
|
||
root.querySelectorAll('[data-client-id]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
_selectedClientId = btn.dataset.clientId;
|
||
_selectedServiceId = 'email_tenant';
|
||
refreshPanels(root);
|
||
});
|
||
});
|
||
}
|
||
|
||
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) {
|
||
const start = await apiFetch(`/v1/vm112/domains/${encodeURIComponent(domain)}/purge/jobs`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ confirm_domain: confirmDomain, root_password: rootPassword }),
|
||
}, 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 || '';
|
||
if (!confirmDomain || !rootPassword) {
|
||
if (msg) msg.textContent = 'Preencha domínio e senha 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);
|
||
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() {
|
||
closePurgeDrawer();
|
||
const modal = document.getElementById('vm112-domain-modal');
|
||
if (!modal) return;
|
||
modal.classList.add('hidden');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
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');
|
||
const sub = document.getElementById('vm112-domain-modal-sub');
|
||
if (!modal || !body) return;
|
||
modal.classList.remove('hidden');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
title.textContent = domain;
|
||
sub.textContent = 'E-mail Tenant · a carregar…';
|
||
body.innerHTML = '<p class="loading">A carregar detalhe VM112…</p>';
|
||
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)}`, {}, 180000);
|
||
const accN = (d.accounts || []).length;
|
||
sub.textContent = `E-mail Tenant · ${accN} conta(s) · ${d.mail_host || ''}`;
|
||
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(a)}</li>`).join('') || '<li class="muted">Nenhuma</li>';
|
||
const cf = d.cloudflare_zone;
|
||
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera';
|
||
body.innerHTML = `
|
||
<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</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"/>
|
||
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
|
||
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
||
</div>`;
|
||
body.querySelector('#vm112-purge-btn')?.addEventListener('click', () => runPurge(domain));
|
||
} catch (e) {
|
||
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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'));
|
||
if (poll && hasPage) {
|
||
try {
|
||
await loadDomains();
|
||
refreshPanels(container);
|
||
setPollStatus(container, '');
|
||
} catch (e) {
|
||
setPollStatus(container, `Actualização falhou: ${e.message}`, true);
|
||
}
|
||
return;
|
||
}
|
||
if (!hasPage) {
|
||
container.innerHTML = '<p class="loading">A carregar clientes e serviços VM112…</p>';
|
||
}
|
||
try {
|
||
await loadDomains();
|
||
if (_clients.length && !_selectedClientId) {
|
||
_selectedClientId = _clients[0].id;
|
||
}
|
||
container.innerHTML = pageHtml();
|
||
bindPage(container);
|
||
setPollStatus(container, '');
|
||
} catch (e) {
|
||
if (hasPage) {
|
||
setPollStatus(container, `Erro ao carregar VM112: ${e.message}`, true);
|
||
return;
|
||
}
|
||
container.innerHTML = `<div class="servicos-page"><p class="servicos-empty">Erro ao carregar VM112: ${esc(e.message)}</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;
|