/** * 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 `
${total}clientes
${emailActive}tenant e-mail ativo
${sites}tenant site CMS
${logins}logins portal
${billingActive}recorrências
`; } function clientRow(c) { const sel = _selectedClientId === c.id ? ' servicos-client-row--selected' : ''; const emailSt = serviceStatus('email_tenant', c); const offers = activeOfferCount(c); return ` `; } function clientsListHtml() { const rows = filteredClients(); return rows.length ? rows.map(clientRow).join('') : '

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(dm)}

${esc(service.label)}

${esc(service.desc)}

${tag} ${wizNote ? `${esc(wizNote)}` : ''} ${clickable ? 'Gerir / Purge →' : ''}
`; } function servicesGridHtml() { const client = selectedClient(); if (!client) { return '
Seleccione um cliente à esquerda para ver os tenants de oferta.
'; } 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 `

${esc(CATEGORY_LABELS[cat])}

${items.map((s) => serviceTile(s, client)).join('')}
`; }).join(''); return `
${esc(client.displayName)} ${esc(client.subtitle)} · ${esc(client.raw.mail_host || '')}
${sections}

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) => `
${esc(s.label)} ${esc(s.desc)}
`).join(''); } function pageHtml() { return `

Orquestração de Serviços

Desk VM122 · Orquestração MOSP

Clientes e tenants de oferta — cada produto com wizard próprio; aqui gere estado OPS e purge de teste.

${statsHtml()}

Clientes ${filteredClients().length}

${clientsListHtml()}

Tenants de Oferta

${servicesGridHtml()}
`; } 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 = '

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 `
  • ${esc(formatTs(step.at))}
    ${esc(step.label)} ${step.detail ? `${esc(step.detail)}` : ''}
  • `; }).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 = '

    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) => `
  • ${esc(s.label)} — ${esc(s.message)}
  • `) .join(''); const accounts = (d.accounts || []) .map((a) => `
  • ${esc(a)}
  • `).join('') || '
  • Nenhuma
  • '; const cf = d.cloudflare_zone; const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : 'Sem zona Cloudflare Ibytera'; body.innerHTML = ` `; body.querySelector('#vm112-purge-btn')?.addEventListener('click', () => runPurge(domain)); } catch (e) { body.innerHTML = `

    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)}

    `; container.querySelector('#servicos-retry')?.addEventListener('click', () => renderPage(container)); } } return { renderPage, loadDomains, openModal, canAccess, SERVICE_CATALOG }; })(); window.DeskServices = DeskServices; window.DeskAccounts = DeskServices;