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