obsidian-vault/ligbox-ops-platform/frontend/assets/accounts.js
2026-06-19 17:26:42 +00:00

897 lines
33 KiB
JavaScript
Raw Permalink 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 = {};
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, '&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.';
}
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');
_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;