${esc(service.label)}
${esc(service.desc)}
${tag} ${wizNote ? `${esc(wizNote)}` : ''} ${clickable ? 'Gerir / Purge →' : ''}/** * 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, '"'); } 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'); _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 `
Nenhum cliente encontrado.
'; } 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 `${esc(service.desc)}
${tag} ${wizNote ? `${esc(wizNote)}` : ''} ${clickable ? 'Gerir / Purge →' : ''}Cada oferta MOSP terá wizard próprio (VM112 = só e-mail). Orquestração aqui no Desk VM122 — sem alterar a Hero do portal.
`; } function scopesHtml() { const client = selectedClient(); if (!client) { return 'Escolha um cliente e o serviço E-mail Tenant para ver o escopo de purge OPS.
'; } if (_selectedServiceId !== 'email_tenant') { return `Escopo OPS detalhado disponível para E-mail Tenant. Serviço actual: ${esc(SERVICE_CATALOG.find((s) => s.id === _selectedServiceId)?.label || _selectedServiceId)}.
`; } return OPS_SCOPES.map((s) => `Desk VM122 · Orquestração MOSP
Clientes e tenants de oferta — cada produto com wizard próprio; aqui gere estado OPS e purge de teste.
A actualizar…
'; 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 `A carregar detalhe VM112…
'; 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) => `Cliente / domínio: ${esc(domain)}
Admin portal: ${esc(d.portal_admin_email || '—')}
Carbonio: ${d.carbonio_exists ? 'ativo' : 'ausente'}
Pasta site: ${d.site_folder_exists ? 'sim' : 'não'}
Cloudflare: ${esc(cfTxt)}
Remove Carbonio, site, portal users, Cloudflare, Traefik e registos Desk. Irreversível. Requer senha Root.
Erro: ${esc(e.message)}
`; } } 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 = 'Sem permissão — perfil Admin (super_admin ou ops_lead) necessário.
'; 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 = 'A carregar clientes e serviços VM112…
'; } 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 = `Erro ao carregar VM112: ${esc(e.message)}