feat(desk): Serviços IaaS perf, Escopo OPS cards e blocklist UI (Spec 017/018)
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>
This commit is contained in:
parent
1f340ef924
commit
edffd8b3c0
25 changed files with 3583 additions and 201 deletions
|
|
@ -153,3 +153,13 @@ docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||||
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
||||||
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
||||||
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Continuação — 2026-06-25 (performance + UX Escopo OPS)
|
||||||
|
|
||||||
|
Ver documento dedicado:
|
||||||
|
|
||||||
|
**`docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md`**
|
||||||
|
|
||||||
|
Inclui: cache VM112/VM122, loading modal purge, cards Escopo OPS clicáveis, blocklist UI, versão frontend `20260625align1`.
|
||||||
|
|
|
||||||
124
LAPTOP/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
124
LAPTOP/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Anais de Referência — Serviços IaaS: Performance, UX Escopo OPS, Blocklist
|
||||||
|
|
||||||
|
**Data:** 2026-06-25
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Sistema:** Desk VM122 (`10.10.10.122`) · Wizard VM112 (`10.10.10.112`)
|
||||||
|
**Specs:** 017 · 018 · 026 · 028 · 032 · 037
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão de melhorias na página **Serviços IaaS** (Desk):
|
||||||
|
|
||||||
|
1. **Performance** — cache VM112 lista + detalhe domínio; cache proxy VM122; SWR frontend.
|
||||||
|
2. **Modal purge** — skeleton imediato + loading animado (barra, etapas, spinner).
|
||||||
|
3. **Escopo OPS** — cards clicáveis com camada (tenant/app/infra), Spec e navegação.
|
||||||
|
4. **Blocklist UI** — `ligbox.com.br` e `itecnologys.com` visíveis na lista e painel Escopo.
|
||||||
|
5. **Alinhamento lista clientes** — badges agrupados (fix desalinhamento `itecnologys.com`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Performance — resultados
|
||||||
|
|
||||||
|
| Pedido | Antes | Depois (cache quente) |
|
||||||
|
|--------|-------|------------------------|
|
||||||
|
| VM112 `GET /api/admin/domains` | ~5–11s | **~0,03s** |
|
||||||
|
| VM112 `GET /api/admin/domains/{domain}` | ~15–20s | **~0,04s** |
|
||||||
|
| Desk `GET /api/v1/vm112/domains` | ~16s | **~0,08s** |
|
||||||
|
| Desk detalhe domínio (2.º hit) | ~15s+ | **~0,1s** |
|
||||||
|
| Modal purge 1.º hit (cold) | ecrã branco | skeleton + ~10–15s |
|
||||||
|
|
||||||
|
**Patch VM112:** `deploy/vm112-wizard/perf-domains-list-20260625/`
|
||||||
|
**Desk API:** `VM112_DOMAINS_CACHE_TTL=60`, `VM112_DOMAIN_DETAIL_TTL=45`
|
||||||
|
**Poll Serviços:** 90s (`app.js`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UX Escopo OPS — mapa cards
|
||||||
|
|
||||||
|
| Card | Camada | Spec | Destino |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| Carbonio | Aplicação MOSP | 017 | Modal Serviços |
|
||||||
|
| ligbox-sites | Tenant utilizador | 017 | Modal Serviços |
|
||||||
|
| Portal users | Tenant utilizador | 028 | Console `/admin/dominio` |
|
||||||
|
| Cloudflare | Infraestrutura | 037 | Console DNS Viewer |
|
||||||
|
| Traefik / SNI | Infraestrutura | 026 | Desk Infra CODE |
|
||||||
|
| Desk OPS | Aplicação MOSP | 017 | Eventos › Histórico purges |
|
||||||
|
|
||||||
|
**Helper navegação:** `window.DeskNavigate.go(view, { eventsTab })` — `app.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Blocklist purge
|
||||||
|
|
||||||
|
| Domínio | Purge API | UI |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `ligbox.com.br` | HTTP 400 | 🔒 badge + banner Escopo + modal desactivado |
|
||||||
|
| `itecnologys.com` | HTTP 400 | idem |
|
||||||
|
|
||||||
|
Constante: `PURGE_BLOCKLIST` — `vm112_domains.py` + espelho `servicos.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ficheiros principais
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `frontend/assets/servicos.js` | Escopo OPS, loading modal, blocklist, cache SWR |
|
||||||
|
| `frontend/assets/styles.css` | `.vm112-load-*`, `.servicos-scope-*`, client row grid |
|
||||||
|
| `frontend/assets/app.js` | `DeskNavigate`, poll 90s |
|
||||||
|
| `frontend/assets/auth.js` | sessão resiliente rede |
|
||||||
|
| `frontend/index.html` | cache bust `20260625align1` |
|
||||||
|
| `api/app/vm112_domains.py` | cache proxy, `purge_blocked` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploy VM122 (referência)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshpass -p '805353' scp servicos.js styles.css app.js index.html root@10.10.10.122:/tmp/
|
||||||
|
ssh root@10.10.10.122 '
|
||||||
|
C=ligbox-ops-platform_frontend_1
|
||||||
|
docker cp /tmp/servicos.js $C:/usr/share/nginx/html/assets/servicos.js
|
||||||
|
docker cp /tmp/styles.css $C:/usr/share/nginx/html/assets/styles.css
|
||||||
|
docker cp /tmp/app.js $C:/usr/share/nginx/html/assets/app.js
|
||||||
|
docker cp /tmp/index.html $C:/usr/share/nginx/html/index.html
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard refresh:** Ctrl+Shift+R em `https://desk.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Versão frontend (cache bust)
|
||||||
|
|
||||||
|
| Asset | Query string |
|
||||||
|
|-------|----------------|
|
||||||
|
| `styles.css` | `?v=20260625align1` |
|
||||||
|
| `servicos.js` | `?v=20260625align1` |
|
||||||
|
| `app.js` | `?v=20260625align1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Specs actualizadas
|
||||||
|
|
||||||
|
| Spec | Secção |
|
||||||
|
|------|--------|
|
||||||
|
| 017 | Performance cache, UI loading/blocklist/Escopo |
|
||||||
|
| 018 | Performance, Escopo OPS cards, critérios aceite |
|
||||||
|
| 032 | UI blocklist |
|
||||||
|
| 026 | Destino card Traefik |
|
||||||
|
| 028 | Destino card Portal |
|
||||||
|
| 037 | Destino card Cloudflare |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Canais de arquivo
|
||||||
|
|
||||||
|
| Canal | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Anais | `docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md` |
|
||||||
|
| Spec 017 | `specs/017-vm112-domain-orchestration/spec.md` |
|
||||||
|
| Spec 018 | `specs/018-service-orchestration/spec.md` |
|
||||||
|
| Patch VM112 | `deploy/vm112-wizard/perf-domains-list-20260625/` |
|
||||||
|
|
@ -153,3 +153,13 @@ docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||||
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
||||||
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
||||||
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Continuação — 2026-06-25 (performance + UX Escopo OPS)
|
||||||
|
|
||||||
|
Ver documento dedicado:
|
||||||
|
|
||||||
|
**`docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md`**
|
||||||
|
|
||||||
|
Inclui: cache VM112/VM122, loading modal purge, cards Escopo OPS clicáveis, blocklist UI, versão frontend `20260625align1`.
|
||||||
|
|
|
||||||
124
LAPTOP/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
124
LAPTOP/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Anais de Referência — Serviços IaaS: Performance, UX Escopo OPS, Blocklist
|
||||||
|
|
||||||
|
**Data:** 2026-06-25
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Sistema:** Desk VM122 (`10.10.10.122`) · Wizard VM112 (`10.10.10.112`)
|
||||||
|
**Specs:** 017 · 018 · 026 · 028 · 032 · 037
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão de melhorias na página **Serviços IaaS** (Desk):
|
||||||
|
|
||||||
|
1. **Performance** — cache VM112 lista + detalhe domínio; cache proxy VM122; SWR frontend.
|
||||||
|
2. **Modal purge** — skeleton imediato + loading animado (barra, etapas, spinner).
|
||||||
|
3. **Escopo OPS** — cards clicáveis com camada (tenant/app/infra), Spec e navegação.
|
||||||
|
4. **Blocklist UI** — `ligbox.com.br` e `itecnologys.com` visíveis na lista e painel Escopo.
|
||||||
|
5. **Alinhamento lista clientes** — badges agrupados (fix desalinhamento `itecnologys.com`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Performance — resultados
|
||||||
|
|
||||||
|
| Pedido | Antes | Depois (cache quente) |
|
||||||
|
|--------|-------|------------------------|
|
||||||
|
| VM112 `GET /api/admin/domains` | ~5–11s | **~0,03s** |
|
||||||
|
| VM112 `GET /api/admin/domains/{domain}` | ~15–20s | **~0,04s** |
|
||||||
|
| Desk `GET /api/v1/vm112/domains` | ~16s | **~0,08s** |
|
||||||
|
| Desk detalhe domínio (2.º hit) | ~15s+ | **~0,1s** |
|
||||||
|
| Modal purge 1.º hit (cold) | ecrã branco | skeleton + ~10–15s |
|
||||||
|
|
||||||
|
**Patch VM112:** `deploy/vm112-wizard/perf-domains-list-20260625/`
|
||||||
|
**Desk API:** `VM112_DOMAINS_CACHE_TTL=60`, `VM112_DOMAIN_DETAIL_TTL=45`
|
||||||
|
**Poll Serviços:** 90s (`app.js`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UX Escopo OPS — mapa cards
|
||||||
|
|
||||||
|
| Card | Camada | Spec | Destino |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| Carbonio | Aplicação MOSP | 017 | Modal Serviços |
|
||||||
|
| ligbox-sites | Tenant utilizador | 017 | Modal Serviços |
|
||||||
|
| Portal users | Tenant utilizador | 028 | Console `/admin/dominio` |
|
||||||
|
| Cloudflare | Infraestrutura | 037 | Console DNS Viewer |
|
||||||
|
| Traefik / SNI | Infraestrutura | 026 | Desk Infra CODE |
|
||||||
|
| Desk OPS | Aplicação MOSP | 017 | Eventos › Histórico purges |
|
||||||
|
|
||||||
|
**Helper navegação:** `window.DeskNavigate.go(view, { eventsTab })` — `app.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Blocklist purge
|
||||||
|
|
||||||
|
| Domínio | Purge API | UI |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `ligbox.com.br` | HTTP 400 | 🔒 badge + banner Escopo + modal desactivado |
|
||||||
|
| `itecnologys.com` | HTTP 400 | idem |
|
||||||
|
|
||||||
|
Constante: `PURGE_BLOCKLIST` — `vm112_domains.py` + espelho `servicos.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ficheiros principais
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `frontend/assets/servicos.js` | Escopo OPS, loading modal, blocklist, cache SWR |
|
||||||
|
| `frontend/assets/styles.css` | `.vm112-load-*`, `.servicos-scope-*`, client row grid |
|
||||||
|
| `frontend/assets/app.js` | `DeskNavigate`, poll 90s |
|
||||||
|
| `frontend/assets/auth.js` | sessão resiliente rede |
|
||||||
|
| `frontend/index.html` | cache bust `20260625align1` |
|
||||||
|
| `api/app/vm112_domains.py` | cache proxy, `purge_blocked` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploy VM122 (referência)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshpass -p '805353' scp servicos.js styles.css app.js index.html root@10.10.10.122:/tmp/
|
||||||
|
ssh root@10.10.10.122 '
|
||||||
|
C=ligbox-ops-platform_frontend_1
|
||||||
|
docker cp /tmp/servicos.js $C:/usr/share/nginx/html/assets/servicos.js
|
||||||
|
docker cp /tmp/styles.css $C:/usr/share/nginx/html/assets/styles.css
|
||||||
|
docker cp /tmp/app.js $C:/usr/share/nginx/html/assets/app.js
|
||||||
|
docker cp /tmp/index.html $C:/usr/share/nginx/html/index.html
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard refresh:** Ctrl+Shift+R em `https://desk.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Versão frontend (cache bust)
|
||||||
|
|
||||||
|
| Asset | Query string |
|
||||||
|
|-------|----------------|
|
||||||
|
| `styles.css` | `?v=20260625align1` |
|
||||||
|
| `servicos.js` | `?v=20260625align1` |
|
||||||
|
| `app.js` | `?v=20260625align1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Specs actualizadas
|
||||||
|
|
||||||
|
| Spec | Secção |
|
||||||
|
|------|--------|
|
||||||
|
| 017 | Performance cache, UI loading/blocklist/Escopo |
|
||||||
|
| 018 | Performance, Escopo OPS cards, critérios aceite |
|
||||||
|
| 032 | UI blocklist |
|
||||||
|
| 026 | Destino card Traefik |
|
||||||
|
| 028 | Destino card Portal |
|
||||||
|
| 037 | Destino card Cloudflare |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Canais de arquivo
|
||||||
|
|
||||||
|
| Canal | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Anais | `docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md` |
|
||||||
|
| Spec 017 | `specs/017-vm112-domain-orchestration/spec.md` |
|
||||||
|
| Spec 018 | `specs/018-service-orchestration/spec.md` |
|
||||||
|
| Patch VM112 | `deploy/vm112-wizard/perf-domains-list-20260625/` |
|
||||||
63
deploy/vm112-wizard/perf-domains-list-20260625/README.md
Normal file
63
deploy/vm112-wizard/perf-domains-list-20260625/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# VM112 — cache `GET /api/admin/domains` (2026-06-25)
|
||||||
|
|
||||||
|
**Problema:** Serviços IaaS (Desk) demorava 10–16s — `zmprov gad` ~5s por pedido, sem cache.
|
||||||
|
|
||||||
|
**Patch:** cache em memória no wizard VM112 + invalidação em cd/dd/purge.
|
||||||
|
|
||||||
|
## Ficheiros
|
||||||
|
|
||||||
|
| Ficheiro | Destino VM112 |
|
||||||
|
|----------|----------------|
|
||||||
|
| `carbonio_cache.py` | `/opt/ligbox-wizard/backend/app/services/` |
|
||||||
|
| `carbonio.py` | idem |
|
||||||
|
| `domain_orchestration.py` | idem |
|
||||||
|
| `infrastructure.py` | idem |
|
||||||
|
| `admin_domains.py` | `/opt/ligbox-wizard/backend/app/routers/` |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp perf-domains-list-20260625/*.py root@10.10.10.112:/tmp/
|
||||||
|
ssh root@10.10.10.112 '
|
||||||
|
W=/opt/ligbox-wizard/backend/app
|
||||||
|
cp /tmp/carbonio_cache.py $W/services/
|
||||||
|
cp /tmp/carbonio.py $W/services/
|
||||||
|
cp /tmp/domain_orchestration.py $W/services/
|
||||||
|
cp /tmp/infrastructure.py $W/services/
|
||||||
|
cp /tmp/admin_domains.py $W/routers/
|
||||||
|
systemctl restart ligbox-wizard
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Env (opcional)
|
||||||
|
|
||||||
|
| Variável | Default | Descrição |
|
||||||
|
|----------|---------|-----------|
|
||||||
|
| — | `TTL_ALL_DOMAINS=90` | Cache `zmprov gad` (carbonio_cache.py) |
|
||||||
|
| — | `TTL_ORCH_LIST=60` | Cache lista orquestrada montada |
|
||||||
|
| Desk `VM112_DOMAINS_CACHE_TTL` | 60 | Cache proxy VM122 |
|
||||||
|
|
||||||
|
## Validação
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 2.º hit lista — deve ser <1s
|
||||||
|
curl -s -H "X-Api-Key: $KEY" http://10.10.10.112:8090/api/admin/domains | jq '.cached, .cache_age_sec'
|
||||||
|
|
||||||
|
# Desk detalhe — purge_blocked para blocklist
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://api.ops.ligbox.com.br/api/v1/vm112/domains/ligbox.com.br | jq '.purge_blocked'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Documentação:** Spec 017/018 § Performance · anais `docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md` · frontend `?v=20260625align1`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lista — 1.º ~5–6s, 2.º <0.1s
|
||||||
|
curl -s -H "X-Api-Key: $KEY" http://10.10.10.112:8090/api/admin/domains | jq '.cached, .cache_age_sec, (.domains|length)'
|
||||||
|
|
||||||
|
# Detalhe purge modal — 1.º ~16s, 2.º <0.1s
|
||||||
|
curl -s -H "X-Api-Key: $KEY" http://10.10.10.112:8090/api/admin/domains/diarissima.com | jq '.domain, (.accounts|length), (.infra_status.steps|length)'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Restaurar backup ou git anterior em `/opt/ligbox-wizard/backend/app/services/` + `systemctl restart ligbox-wizard`.
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""API admin — domínios orquestrados (Desk Spec 017)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Query
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import domain_orchestration, purge_jobs
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/domains", tags=["admin-domains"])
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(x_api_key: str | None = Header(default=None, alias="X-Api-Key")):
|
||||||
|
if x_api_key != settings.admin_api_key:
|
||||||
|
raise HTTPException(401, "API key inválida")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/purge-jobs/{job_id}", dependencies=[Depends(require_api_key)])
|
||||||
|
def get_purge_job(job_id: str):
|
||||||
|
job = purge_jobs.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(404, "Job de purge não encontrado")
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", dependencies=[Depends(require_api_key)])
|
||||||
|
def list_domains(q: str = Query(default="")):
|
||||||
|
return domain_orchestration.list_orchestrated_domains_meta(q)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain}", dependencies=[Depends(require_api_key)])
|
||||||
|
def domain_detail(domain: str):
|
||||||
|
try:
|
||||||
|
return domain_orchestration.get_domain_detail(domain.lower().strip())
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{domain}/purge", dependencies=[Depends(require_api_key)])
|
||||||
|
def purge_domain(domain: str, background_tasks: BackgroundTasks, sync: bool = Query(default=False)):
|
||||||
|
"""Inicia purge assíncrono (Fase 3). Desk faz poll em purge-jobs/{job_id}."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
try:
|
||||||
|
domain_orchestration.assert_purge_allowed(domain)
|
||||||
|
if sync:
|
||||||
|
result = domain_orchestration.purge_domain(domain)
|
||||||
|
return {"ok": True, "steps": result.get("steps", []), "result": result}
|
||||||
|
meta = purge_jobs.create_job(domain)
|
||||||
|
background_tasks.add_task(domain_orchestration.run_purge_job, meta["job_id"])
|
||||||
|
return {"ok": True, **meta}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)) from e
|
||||||
485
deploy/vm112-wizard/perf-domains-list-20260625/carbonio.py
Normal file
485
deploy/vm112-wizard/perf-domains-list-20260625/carbonio.py
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
"""Provisionamento local via zmprov (Carbonio)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
NGINX_WEB_HTTPS = Path("/opt/zextras/conf/nginx/includes/nginx.conf.web.https")
|
||||||
|
ZMPROXYCONFGEN = "/opt/zextras/libexec/zmproxyconfgen"
|
||||||
|
from app.services import activity_log, carbonio_cache
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
|
||||||
|
_NO_SUCH_DOMAIN = "NO_SUCH_DOMAIN"
|
||||||
|
_NO_SUCH_ACCOUNT = "NO_SUCH_ACCOUNT"
|
||||||
|
|
||||||
|
|
||||||
|
class CarbonioError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _zmprov_run(*args: str, log_cmd: bool = True) -> tuple[int, str, str]:
|
||||||
|
cmd = ["sudo", "-u", settings.zextras_user, settings.zmprov, *args]
|
||||||
|
if log_cmd:
|
||||||
|
activity_log.cmd(" ".join(cmd), source="vm112")
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
return proc.returncode, (proc.stdout or "").strip(), (proc.stderr or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_zmprov(*args: str) -> str:
|
||||||
|
"""Comando zmprov que deve ter sucesso (criar domínio/conta, etc.)."""
|
||||||
|
code, out, err = _zmprov_run(*args, log_cmd=True)
|
||||||
|
if code != 0:
|
||||||
|
msg = err or out or "zmprov failed"
|
||||||
|
activity_log.error(f"zmprov falhou: {msg}", source="vm112")
|
||||||
|
raise CarbonioError(msg)
|
||||||
|
if out:
|
||||||
|
activity_log.ok(out[:500], source="vm112")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_domain(stderr: str, stdout: str) -> bool:
|
||||||
|
blob = f"{stderr}\n{stdout}"
|
||||||
|
return _NO_SUCH_DOMAIN in blob
|
||||||
|
|
||||||
|
|
||||||
|
def _is_missing_account(stderr: str, stdout: str) -> bool:
|
||||||
|
blob = f"{stderr}\n{stdout}"
|
||||||
|
return _NO_SUCH_ACCOUNT in blob or _NO_SUCH_DOMAIN in blob
|
||||||
|
|
||||||
|
|
||||||
|
def _is_account_exists(stderr: str, stdout: str) -> bool:
|
||||||
|
blob = f"{stderr}\n{stdout}"
|
||||||
|
return "ACCOUNT_EXISTS" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def _carbonio_unreachable(err: str, out: str) -> bool:
|
||||||
|
blob = f"{err}\n{out}".lower()
|
||||||
|
return "connection refused" in blob or "io_error" in blob
|
||||||
|
|
||||||
|
|
||||||
|
def domain_exists(domain: str, *, use_cache: bool = True) -> bool:
|
||||||
|
"""Verificação silenciosa — domínio inexistente NÃO é erro de onboarding."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
cache_key = f"domain_exists:{domain}"
|
||||||
|
if use_cache:
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return bool(cached)
|
||||||
|
code, out, err = _zmprov_run("gd", domain, log_cmd=False)
|
||||||
|
if code == 0:
|
||||||
|
exists = f"# name {domain}" in out or f"name {domain}" in out
|
||||||
|
elif _is_missing_domain(err, out):
|
||||||
|
exists = False
|
||||||
|
elif _carbonio_unreachable(err, out):
|
||||||
|
exists = False
|
||||||
|
else:
|
||||||
|
activity_log.warn(f"zmprov gd {domain}: {err or out}", source="vm112")
|
||||||
|
exists = False
|
||||||
|
if use_cache:
|
||||||
|
carbonio_cache.set(cache_key, exists, carbonio_cache.TTL_DOMAIN_EXISTS)
|
||||||
|
return exists
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_domains(*, use_cache: bool = True) -> list[str]:
|
||||||
|
"""zmprov gad (~5s) — cache TTL curto para Serviços IaaS / Desk."""
|
||||||
|
cache_key = carbonio_cache.CACHE_KEY_ALL_DOMAINS
|
||||||
|
if use_cache:
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
code, out, _err = _zmprov_run("gad", log_cmd=False)
|
||||||
|
if code != 0:
|
||||||
|
return []
|
||||||
|
domains = [ln.strip().lower() for ln in out.splitlines() if ln.strip()]
|
||||||
|
if use_cache:
|
||||||
|
carbonio_cache.set(cache_key, domains, carbonio_cache.TTL_ALL_DOMAINS)
|
||||||
|
return domains
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_domain_list_cache() -> None:
|
||||||
|
carbonio_cache.invalidate_all_domains()
|
||||||
|
|
||||||
|
|
||||||
|
def set_domain_public_hostname(domain: str) -> str:
|
||||||
|
"""Webmail mail.{domínio} — evita redirect para mail.ligbox.com.br."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
mail_host = f"mail.{domain}"
|
||||||
|
logo = f"https://{mail_host}/public/logos/ligbox-logo.png"
|
||||||
|
admin = f"admin@{domain}"
|
||||||
|
_run_zmprov(
|
||||||
|
"md",
|
||||||
|
domain,
|
||||||
|
"zimbraPublicServiceHostname",
|
||||||
|
mail_host,
|
||||||
|
"zimbraVirtualHostname",
|
||||||
|
mail_host,
|
||||||
|
"zimbraPublicServiceProtocol",
|
||||||
|
"https",
|
||||||
|
"zimbraPublicServicePort",
|
||||||
|
"443",
|
||||||
|
"zimbraSkinLogoURL",
|
||||||
|
logo,
|
||||||
|
"carbonioLogoUrl",
|
||||||
|
logo,
|
||||||
|
"carbonioNotificationFrom",
|
||||||
|
admin,
|
||||||
|
"carbonioNotificationRecipients",
|
||||||
|
admin,
|
||||||
|
)
|
||||||
|
return mail_host
|
||||||
|
|
||||||
|
|
||||||
|
def add_mail_alias_hostnames(domain: str, hostnames: list[str]) -> None:
|
||||||
|
"""Adiciona hostnames virtuais (alias webmail) ao domínio Carbonio."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
primary_mail = f"mail.{domain}"
|
||||||
|
for host in hostnames:
|
||||||
|
h = host.lower().strip().rstrip(".")
|
||||||
|
if not h or h == primary_mail:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_run_zmprov("md", domain, "zimbraVirtualHostname", h)
|
||||||
|
activity_log.ok(f"Alias hostname Carbonio: {h}", source="vm112")
|
||||||
|
except CarbonioError as e:
|
||||||
|
activity_log.warn(f"Alias {h}: {e}", source="vm112")
|
||||||
|
|
||||||
|
|
||||||
|
def create_domain(domain: str) -> str:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if domain_exists(domain):
|
||||||
|
activity_log.info(f"Domínio {domain} já existe no Carbonio", source="vm112")
|
||||||
|
set_domain_public_hostname(domain)
|
||||||
|
return f"domain {domain} already exists"
|
||||||
|
activity_log.info(f"Criar domínio no Carbonio: {domain}", source="vm112")
|
||||||
|
out = _run_zmprov("cd", domain)
|
||||||
|
set_domain_public_hostname(domain)
|
||||||
|
invalidate_domain_list_cache()
|
||||||
|
activity_log.ok(f"Webmail: https://mail.{domain}/", source="vm112")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def create_account(email: str, password: str, display_name: str | None = None) -> str:
|
||||||
|
return create_account_full(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
display_name=display_name,
|
||||||
|
quota_mb=0,
|
||||||
|
must_change_password=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_onboarding_account(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
Cria conta admin no onboarding ou reutiliza se já existir (retry do wizard).
|
||||||
|
Returns (message, reused).
|
||||||
|
"""
|
||||||
|
email = email.lower().strip()
|
||||||
|
domain = email.split("@", 1)[1]
|
||||||
|
if account_exists(email):
|
||||||
|
activity_log.info(
|
||||||
|
f"Conta {email} já existe no Carbonio — actualizar senha e continuar",
|
||||||
|
source="vm112",
|
||||||
|
)
|
||||||
|
if display_name:
|
||||||
|
try:
|
||||||
|
_run_zmprov("ma", email, "displayName", display_name)
|
||||||
|
except CarbonioError:
|
||||||
|
pass
|
||||||
|
set_password(email, password)
|
||||||
|
set_password_must_change(email, False)
|
||||||
|
invalidate_domain_cache(domain)
|
||||||
|
return f"account {email} already exists (password updated)", True
|
||||||
|
msg = create_account(email, password, display_name)
|
||||||
|
return msg, False
|
||||||
|
|
||||||
|
|
||||||
|
def create_account_full(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
display_name: str | None = None,
|
||||||
|
quota_mb: int = 0,
|
||||||
|
must_change_password: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Uma única chamada zmprov ca (evita 3–4 round-trips de ~5s cada)."""
|
||||||
|
if not EMAIL_RE.match(email):
|
||||||
|
raise CarbonioError("invalid email")
|
||||||
|
domain = email.split("@", 1)[1]
|
||||||
|
if not domain_exists(domain):
|
||||||
|
create_domain(domain)
|
||||||
|
activity_log.info(f"Criar conta: {email}", source="vm112")
|
||||||
|
args = ["ca", email, password]
|
||||||
|
if display_name:
|
||||||
|
args.extend(["displayName", display_name])
|
||||||
|
if quota_mb > 0:
|
||||||
|
args.extend(["zimbraMailQuota", str(int(quota_mb) * 1024 * 1024)])
|
||||||
|
if must_change_password:
|
||||||
|
args.extend(["zimbraPasswordMustChange", "TRUE"])
|
||||||
|
out = _run_zmprov(*args)
|
||||||
|
invalidate_domain_cache(domain)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def account_summary_fast(
|
||||||
|
email: str,
|
||||||
|
*,
|
||||||
|
display_name: str | None = None,
|
||||||
|
quota_mb: int = 0,
|
||||||
|
must_change_password: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Resposta imediata sem zmprov ga (~5s)."""
|
||||||
|
quota_bytes = int(quota_mb) * 1024 * 1024 if quota_mb > 0 else 0
|
||||||
|
return {
|
||||||
|
"email": email.lower(),
|
||||||
|
"display_name": display_name or "",
|
||||||
|
"status": "active",
|
||||||
|
"active": True,
|
||||||
|
"suspended": False,
|
||||||
|
"quota_bytes": quota_bytes,
|
||||||
|
"quota_label": _quota_label(str(quota_bytes)),
|
||||||
|
"two_factor_enabled": False,
|
||||||
|
"password_must_change": must_change_password,
|
||||||
|
"last_logon": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_accounts(domain: str, *, use_cache: bool = True) -> list[str]:
|
||||||
|
"""Lista contas do domínio (zmprov -l gaa ~3–5s — cache TTL curto)."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
cache_key = f"accounts_list:{domain}"
|
||||||
|
if use_cache:
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
code, out, err = _zmprov_run("-l", "gaa", domain, log_cmd=False)
|
||||||
|
if code != 0:
|
||||||
|
msg = err or out or "zmprov gaa failed"
|
||||||
|
raise CarbonioError(msg)
|
||||||
|
result = sorted(line.strip() for line in out.splitlines() if line.strip())
|
||||||
|
if use_cache:
|
||||||
|
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(email: str, password: str) -> str:
|
||||||
|
return _run_zmprov("sp", email, password)
|
||||||
|
|
||||||
|
|
||||||
|
def account_exists(email: str) -> bool:
|
||||||
|
code, out, err = _zmprov_run("ga", email, log_cmd=False)
|
||||||
|
if code == 0:
|
||||||
|
return email.lower() in out.lower()
|
||||||
|
if _is_missing_account(err, out):
|
||||||
|
return False
|
||||||
|
activity_log.warn(f"zmprov ga {email}: {err or out}", source="vm112")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ga_attributes(out: str) -> dict[str, str]:
|
||||||
|
attrs: dict[str, str] = {}
|
||||||
|
for line in out.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
attrs[key.strip()] = val.strip()
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
def get_account_attributes(email: str) -> dict[str, str]:
|
||||||
|
code, out, err = _zmprov_run("ga", email, log_cmd=False)
|
||||||
|
if code != 0:
|
||||||
|
raise CarbonioError(err or out or f"Conta {email} não encontrada")
|
||||||
|
return _parse_ga_attributes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def get_domain_attributes(domain: str) -> dict[str, str]:
|
||||||
|
code, out, err = _zmprov_run("gd", domain, log_cmd=False)
|
||||||
|
if code != 0:
|
||||||
|
raise CarbonioError(err or out or f"Domínio {domain} não encontrado")
|
||||||
|
return _parse_ga_attributes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_label(bytes_raw: str) -> str:
|
||||||
|
try:
|
||||||
|
n = int(bytes_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return bytes_raw or "—"
|
||||||
|
if n <= 0:
|
||||||
|
return "Ilimitada (COS)"
|
||||||
|
if n >= 1024**3:
|
||||||
|
return f"{n / 1024**3:.1f} GB"
|
||||||
|
if n >= 1024**2:
|
||||||
|
return f"{n / 1024**2:.0f} MB"
|
||||||
|
return f"{n} B"
|
||||||
|
|
||||||
|
|
||||||
|
def account_summary(email: str, *, use_cache: bool = True) -> dict:
|
||||||
|
email = email.lower().strip()
|
||||||
|
cache_key = f"account_summary:{email}"
|
||||||
|
if use_cache:
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
attrs = get_account_attributes(email)
|
||||||
|
status = attrs.get("zimbraAccountStatus", "unknown")
|
||||||
|
quota_b = attrs.get("zimbraMailQuota", "0")
|
||||||
|
tfa = attrs.get("zimbraTwoFactorAuthEnabled", "").upper() == "TRUE"
|
||||||
|
must_change = attrs.get("zimbraPasswordMustChange", "").upper() == "TRUE"
|
||||||
|
last_logon = attrs.get("zimbraLastLogonTimestamp", "")
|
||||||
|
if last_logon and len(last_logon) >= 8:
|
||||||
|
last_logon = f"{last_logon[0:4]}-{last_logon[4:6]}-{last_logon[6:8]}"
|
||||||
|
summary = {
|
||||||
|
"email": email.lower(),
|
||||||
|
"display_name": attrs.get("displayName") or attrs.get("cn") or "",
|
||||||
|
"status": status,
|
||||||
|
"active": status == "active",
|
||||||
|
"suspended": status in ("locked", "closed", "maintenance"),
|
||||||
|
"quota_bytes": int(quota_b) if quota_b.isdigit() else 0,
|
||||||
|
"quota_label": _quota_label(quota_b),
|
||||||
|
"two_factor_enabled": tfa,
|
||||||
|
"password_must_change": must_change,
|
||||||
|
"last_logon": last_logon or None,
|
||||||
|
"_summary": True,
|
||||||
|
}
|
||||||
|
if use_cache:
|
||||||
|
carbonio_cache.set(cache_key, summary, carbonio_cache.TTL_ACCOUNT_SUMMARY)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def list_domain_accounts_fast(domain: str, *, use_cache: bool = True) -> list[dict]:
|
||||||
|
"""Uma chamada zmprov -l gaa (~5s) em vez de ga por conta (~5s × N)."""
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
cache_key = f"accounts_fast:{domain}"
|
||||||
|
if use_cache:
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
emails = list_accounts(domain)
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
"email": e,
|
||||||
|
"display_name": "",
|
||||||
|
"status": "—",
|
||||||
|
"active": True,
|
||||||
|
"suspended": False,
|
||||||
|
"quota_label": "—",
|
||||||
|
"two_factor_enabled": False,
|
||||||
|
"password_must_change": False,
|
||||||
|
"last_logon": None,
|
||||||
|
"_summary": False,
|
||||||
|
}
|
||||||
|
for e in emails
|
||||||
|
]
|
||||||
|
if use_cache:
|
||||||
|
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_ACCOUNTS_LIST)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def list_domain_accounts(domain: str, *, full: bool = False, use_cache: bool = True) -> list[dict]:
|
||||||
|
if not full:
|
||||||
|
return list_domain_accounts_fast(domain, use_cache=use_cache)
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
emails = list_accounts(domain)
|
||||||
|
result = []
|
||||||
|
for email in emails:
|
||||||
|
try:
|
||||||
|
result.append(account_summary(email, use_cache=use_cache))
|
||||||
|
except CarbonioError:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"email": email,
|
||||||
|
"status": "unknown",
|
||||||
|
"active": False,
|
||||||
|
"suspended": False,
|
||||||
|
"_summary": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_domain_cache(domain: str) -> None:
|
||||||
|
carbonio_cache.invalidate_domain(domain.lower().strip())
|
||||||
|
|
||||||
|
|
||||||
|
def set_account_status(email: str, active: bool) -> str:
|
||||||
|
status = "active" if active else "locked"
|
||||||
|
activity_log.info(f"Conta {email} → {status}", source="vm112")
|
||||||
|
out = _run_zmprov("ma", email, "zimbraAccountStatus", status)
|
||||||
|
carbonio_cache.invalidate_account(email)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_mail_quota(email: str, quota_mb: int) -> str:
|
||||||
|
if quota_mb <= 0:
|
||||||
|
bytes_q = "0"
|
||||||
|
else:
|
||||||
|
bytes_q = str(int(quota_mb) * 1024 * 1024)
|
||||||
|
activity_log.info(f"Quota {email} → {quota_mb} MB", source="vm112")
|
||||||
|
out = _run_zmprov("ma", email, "zimbraMailQuota", bytes_q)
|
||||||
|
carbonio_cache.invalidate_account(email)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_password_must_change(email: str, must_change: bool = True) -> str:
|
||||||
|
val = "TRUE" if must_change else "FALSE"
|
||||||
|
out = _run_zmprov("ma", email, "zimbraPasswordMustChange", val)
|
||||||
|
carbonio_cache.invalidate_account(email)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def set_password_with_policy(
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
must_change: bool = True,
|
||||||
|
) -> str:
|
||||||
|
out = set_password(email, password)
|
||||||
|
if must_change:
|
||||||
|
set_password_must_change(email, True)
|
||||||
|
else:
|
||||||
|
carbonio_cache.invalidate_account(email)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def domain_two_factor_capabilities(domain: str) -> dict:
|
||||||
|
attrs = get_domain_attributes(domain)
|
||||||
|
avail = attrs.get("zimbraFeatureTwoFactorAuthAvailable", "FALSE").upper() == "TRUE"
|
||||||
|
required = attrs.get("zimbraFeatureTwoFactorAuthRequired", "FALSE").upper() == "TRUE"
|
||||||
|
return {
|
||||||
|
"available": avail,
|
||||||
|
"required": required,
|
||||||
|
"automated": avail,
|
||||||
|
"message": (
|
||||||
|
"2FA disponível neste domínio (política via painel activa)."
|
||||||
|
if avail
|
||||||
|
else (
|
||||||
|
"Política 2FA via painel inactiva no COS/domínio. "
|
||||||
|
"Utilizadores podem activar OTP em webmail: Settings → Auth → OTP Authentication → NEW OTP."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_user_two_factor(email: str, enabled: bool) -> str:
|
||||||
|
caps = domain_two_factor_capabilities(email.split("@", 1)[1])
|
||||||
|
if not caps["available"]:
|
||||||
|
raise CarbonioError(caps["message"])
|
||||||
|
val = "TRUE" if enabled else "FALSE"
|
||||||
|
activity_log.info(f"2FA {email} → {val}", source="vm112")
|
||||||
|
return _run_zmprov("ma", email, "zimbraTwoFactorAuthEnabled", val)
|
||||||
|
|
||||||
|
|
||||||
|
def set_domain_two_factor_required(domain: str, required: bool) -> str:
|
||||||
|
caps = domain_two_factor_capabilities(domain)
|
||||||
|
if not caps["available"]:
|
||||||
|
raise CarbonioError(caps["message"])
|
||||||
|
val = "TRUE" if required else "FALSE"
|
||||||
|
return _run_zmprov("md", domain, "zimbraFeatureTwoFactorAuthRequired", val)
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Cache em memória para reduzir chamadas lentas ao zmprov."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_STORE: dict[str, tuple[float, Any, float]] = {}
|
||||||
|
|
||||||
|
TTL_ACCOUNTS_LIST = 45
|
||||||
|
TTL_DOMAIN_EXISTS = 120
|
||||||
|
TTL_ACCOUNT_SUMMARY = 60
|
||||||
|
TTL_ALL_DOMAINS = 90
|
||||||
|
TTL_ORCH_LIST = 60
|
||||||
|
TTL_DOMAIN_DETAIL = 120
|
||||||
|
TTL_INFRA_STATUS = 90
|
||||||
|
|
||||||
|
CACHE_KEY_ALL_DOMAINS = "gad_all_domains"
|
||||||
|
CACHE_KEY_ORCH_LIST = "orchestrated_domains_list"
|
||||||
|
|
||||||
|
|
||||||
|
def get(key: str) -> Any | None:
|
||||||
|
with _LOCK:
|
||||||
|
item = _STORE.get(key)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
expires, value, _set_at = item
|
||||||
|
if time.monotonic() > expires:
|
||||||
|
del _STORE[key]
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def set(key: str, value: Any, ttl: int) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
_STORE[key] = (now + ttl, value, now)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_age_sec(key: str) -> int | None:
|
||||||
|
with _LOCK:
|
||||||
|
item = _STORE.get(key)
|
||||||
|
if not item:
|
||||||
|
return None
|
||||||
|
expires, _value, set_at = item
|
||||||
|
now = time.monotonic()
|
||||||
|
if now > expires:
|
||||||
|
return None
|
||||||
|
return int(now - set_at)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_domain(domain: str) -> None:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
with _LOCK:
|
||||||
|
for key in list(_STORE):
|
||||||
|
if domain in key or key.endswith(f":{domain}") or f":{domain}:" in key:
|
||||||
|
del _STORE[key]
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_account(email: str) -> None:
|
||||||
|
email = email.lower().strip()
|
||||||
|
domain = email.split("@", 1)[1] if "@" in email else ""
|
||||||
|
with _LOCK:
|
||||||
|
_STORE.pop(f"account_summary:{email}", None)
|
||||||
|
if domain:
|
||||||
|
invalidate_domain(domain)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_all_domains() -> None:
|
||||||
|
"""Após zmprov cd/dd/purge — lista gad + orquestração Desk."""
|
||||||
|
with _LOCK:
|
||||||
|
_STORE.pop(CACHE_KEY_ALL_DOMAINS, None)
|
||||||
|
_STORE.pop(CACHE_KEY_ORCH_LIST, None)
|
||||||
|
|
@ -0,0 +1,598 @@
|
||||||
|
"""Listagem e purge de domínios orquestrados na VM112 (Spec 017 + 026)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import activity_log, carbonio, carbonio_cache, domain_registry, purge_jobs
|
||||||
|
from app.services.domain_site_layout import PLATFORM_DOMAINS, SITES_ROOT
|
||||||
|
from app.services.infrastructure import _mail_host
|
||||||
|
from app.services.traefik_purge import (
|
||||||
|
_collect_purge_hosts,
|
||||||
|
purge_traefik_routers,
|
||||||
|
purge_traefik_sni,
|
||||||
|
rollback_traefik_backup,
|
||||||
|
validate_traefik_after_purge,
|
||||||
|
)
|
||||||
|
from app.services.purge_snapshot import build_audit_snapshot
|
||||||
|
from app.services.purge_openpanel import purge_openpanel
|
||||||
|
from app.services.purge_pmg import purge_pmg
|
||||||
|
|
||||||
|
_LOG_DIR = Path("/var/log/ibytera-mail-portal/sessions")
|
||||||
|
_WIZARD_PORTAL_USERS_DIR = Path("/var/lib/ligbox-wizard/portal_users")
|
||||||
|
_TENANT_BRANDING = Path("/opt/ligbox-wizard/backend/app/services/tenant_branding.py")
|
||||||
|
_DEPLOY_SCRIPTS = Path("/opt/ligbox-deploy/scripts/admin-login-check")
|
||||||
|
_TRAEFIK_CERT_EXPORT = Path("/opt/zextras/ssl/letsencrypt/traefik-export")
|
||||||
|
_NGINX_INCLUDES = Path("/opt/zextras/conf/nginx/includes")
|
||||||
|
_PURGE_BLOCKLIST = PLATFORM_DOMAINS | frozenset({"itecnologys.com"})
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_slug(domain: str) -> str:
|
||||||
|
return domain.lower().strip().replace(".", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_carbonio_domains() -> list[str]:
|
||||||
|
return carbonio.list_all_domains()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_site_domains() -> list[str]:
|
||||||
|
if not SITES_ROOT.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
p.name.lower()
|
||||||
|
for p in SITES_ROOT.iterdir()
|
||||||
|
if p.is_dir() and (p / "domain.json").is_file()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_users_for_domain(domain: str, users_dir: Path | None = None) -> list[dict[str, Any]]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
base = users_dir or Path(settings.portal_users_dir)
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
found: list[dict[str, Any]] = []
|
||||||
|
for f in base.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
||||||
|
if email.endswith(f"@{domain}"):
|
||||||
|
found.append({
|
||||||
|
"login_id": data.get("login_id") or f.stem,
|
||||||
|
"planned_corporate_email": email,
|
||||||
|
"path": str(f),
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _build_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
|
||||||
|
carbonio_set = set(_list_carbonio_domains())
|
||||||
|
names = sorted(carbonio_set | set(_list_site_domains()))
|
||||||
|
users_by_domain: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
users_dir = Path(settings.portal_users_dir)
|
||||||
|
if users_dir.is_dir():
|
||||||
|
for f in users_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
||||||
|
if "@" not in email:
|
||||||
|
continue
|
||||||
|
dom = email.split("@", 1)[1]
|
||||||
|
users_by_domain.setdefault(dom, []).append({
|
||||||
|
"login_id": data.get("login_id") or f.stem,
|
||||||
|
"planned_corporate_email": email,
|
||||||
|
"path": str(f),
|
||||||
|
})
|
||||||
|
if _WIZARD_PORTAL_USERS_DIR.is_dir():
|
||||||
|
for f in _WIZARD_PORTAL_USERS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
email = (data.get("planned_corporate_email") or "").lower().strip()
|
||||||
|
if "@" not in email:
|
||||||
|
continue
|
||||||
|
dom = email.split("@", 1)[1]
|
||||||
|
entry = {
|
||||||
|
"login_id": data.get("login_id") or f.stem,
|
||||||
|
"planned_corporate_email": email,
|
||||||
|
"path": str(f),
|
||||||
|
}
|
||||||
|
if entry not in users_by_domain.get(dom, []):
|
||||||
|
users_by_domain.setdefault(dom, []).append(entry)
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for domain in names:
|
||||||
|
if domain in PLATFORM_DOMAINS:
|
||||||
|
continue
|
||||||
|
rec = domain_registry.get_domain_record(domain) or {}
|
||||||
|
admin_email = rec.get("portal_admin_email") or ""
|
||||||
|
exists = domain in carbonio_set
|
||||||
|
item = {
|
||||||
|
"domain": domain,
|
||||||
|
"mail_host": _mail_host(domain),
|
||||||
|
"portal_admin_email": admin_email,
|
||||||
|
"carbonio_exists": exists,
|
||||||
|
"site_folder_exists": (SITES_ROOT / domain / "domain.json").is_file(),
|
||||||
|
"account_count": None,
|
||||||
|
"accounts_preview": [],
|
||||||
|
"portal_users": users_by_domain.get(domain, []),
|
||||||
|
"updated_at": rec.get("updated_at"),
|
||||||
|
}
|
||||||
|
if q:
|
||||||
|
blob = " ".join([
|
||||||
|
domain,
|
||||||
|
admin_email,
|
||||||
|
" ".join(u.get("login_id", "") for u in item["portal_users"]),
|
||||||
|
]).lower()
|
||||||
|
if q not in blob:
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_orchestrated_domains(query: str = "") -> list[dict[str, Any]]:
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
cached = carbonio_cache.get(carbonio_cache.CACHE_KEY_ORCH_LIST)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
items = _build_orchestrated_domains(q)
|
||||||
|
if not q:
|
||||||
|
carbonio_cache.set(
|
||||||
|
carbonio_cache.CACHE_KEY_ORCH_LIST,
|
||||||
|
items,
|
||||||
|
carbonio_cache.TTL_ORCH_LIST,
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def list_orchestrated_domains_meta(query: str = "") -> dict[str, Any]:
|
||||||
|
items = list_orchestrated_domains(query)
|
||||||
|
age = carbonio_cache.entry_age_sec(carbonio_cache.CACHE_KEY_ALL_DOMAINS)
|
||||||
|
return {
|
||||||
|
"domains": items,
|
||||||
|
"cached": age is not None,
|
||||||
|
"cache_age_sec": age,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_domain_detail(domain: str) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
detail_key = f"domain_detail:{domain}"
|
||||||
|
cached = carbonio_cache.get(detail_key)
|
||||||
|
if cached is not None:
|
||||||
|
return dict(cached)
|
||||||
|
|
||||||
|
matches = [i for i in list_orchestrated_domains() if i["domain"] == domain]
|
||||||
|
if not matches:
|
||||||
|
raise ValueError(f"Domínio {domain} não encontrado na orquestração")
|
||||||
|
base = dict(matches[0])
|
||||||
|
from app.services.infrastructure import get_status
|
||||||
|
|
||||||
|
rec = domain_registry.get_domain_record(domain) or {}
|
||||||
|
aliases = list(rec.get("mail_aliases") or [])
|
||||||
|
base["domain_record"] = rec
|
||||||
|
carbonio_exists = bool(base.get("carbonio_exists"))
|
||||||
|
|
||||||
|
def fetch_accounts() -> list[str]:
|
||||||
|
if not carbonio_exists:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return carbonio.list_accounts(domain)
|
||||||
|
except carbonio.CarbonioError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_infra() -> dict[str, Any]:
|
||||||
|
return get_status(domain, aliases)
|
||||||
|
|
||||||
|
def fetch_cf() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from app.services.cloudflare import CloudflareDNS
|
||||||
|
|
||||||
|
return CloudflareDNS().get_zone_by_name(domain)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||||
|
fut_acc = pool.submit(fetch_accounts)
|
||||||
|
fut_inf = pool.submit(fetch_infra)
|
||||||
|
fut_cf = pool.submit(fetch_cf)
|
||||||
|
base["accounts"] = fut_acc.result()
|
||||||
|
base["infra_status"] = fut_inf.result()
|
||||||
|
base["cloudflare_zone"] = fut_cf.result()
|
||||||
|
|
||||||
|
carbonio_cache.set(detail_key, base, carbonio_cache.TTL_DOMAIN_DETAIL)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_carbonio_accounts(domain: str) -> list[str]:
|
||||||
|
logs: list[str] = []
|
||||||
|
accounts: list[str] = []
|
||||||
|
try:
|
||||||
|
accounts = carbonio.list_accounts(domain)
|
||||||
|
except carbonio.CarbonioError:
|
||||||
|
pass
|
||||||
|
for email in accounts:
|
||||||
|
code, _out, _err = carbonio._zmprov_run("da", email, log_cmd=True)
|
||||||
|
logs.append(f"da {email}: rc={code}")
|
||||||
|
return logs
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_carbonio_domain_only(domain: str) -> str | None:
|
||||||
|
if carbonio.domain_exists(domain, use_cache=False):
|
||||||
|
code, _out, _err = carbonio._zmprov_run("dd", domain, log_cmd=True)
|
||||||
|
carbonio.invalidate_domain_list_cache()
|
||||||
|
return f"dd {domain}: rc={code}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_portal_users(domain: str) -> list[str]:
|
||||||
|
removed: list[str] = []
|
||||||
|
for users_dir in (Path(settings.portal_users_dir), _WIZARD_PORTAL_USERS_DIR):
|
||||||
|
for u in _portal_users_for_domain(domain, users_dir):
|
||||||
|
try:
|
||||||
|
Path(u["path"]).unlink(missing_ok=True)
|
||||||
|
label = f"{u['login_id']}@{users_dir.name}"
|
||||||
|
if label not in removed:
|
||||||
|
removed.append(label)
|
||||||
|
except OSError as e:
|
||||||
|
removed.append(f"ERR:{u['login_id']}:{e}")
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_site_folder(domain: str) -> bool:
|
||||||
|
path = SITES_ROOT / domain
|
||||||
|
if path.is_dir():
|
||||||
|
shutil.rmtree(path)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_cloudflare_zone(domain: str) -> str:
|
||||||
|
try:
|
||||||
|
from app.services.cloudflare import CloudflareDNS, CloudflareError
|
||||||
|
|
||||||
|
cf = CloudflareDNS()
|
||||||
|
zone = cf.get_zone_by_name(domain)
|
||||||
|
if not zone:
|
||||||
|
return "no_zone"
|
||||||
|
zid = zone.get("id")
|
||||||
|
with cf._client() as c:
|
||||||
|
r = c.delete(f"https://api.cloudflare.com/client/v4/zones/{zid}")
|
||||||
|
data = r.json()
|
||||||
|
if data.get("success"):
|
||||||
|
return f"deleted:{zid}"
|
||||||
|
raise CloudflareError(str(data.get("errors")))
|
||||||
|
except Exception as e:
|
||||||
|
return f"error:{e}"
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_tenant_branding(domain: str) -> str:
|
||||||
|
if not _TENANT_BRANDING.is_file():
|
||||||
|
return "skip"
|
||||||
|
text = _TENANT_BRANDING.read_text(encoding="utf-8")
|
||||||
|
needle = f'"{domain}"'
|
||||||
|
if needle not in text:
|
||||||
|
return "absent"
|
||||||
|
new_text = re.sub(rf'^\s*"{re.escape(domain)}".*\n', "", text, flags=re.MULTILINE)
|
||||||
|
_TENANT_BRANDING.write_text(new_text, encoding="utf-8")
|
||||||
|
return "removed"
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_deploy_script_refs(domain: str) -> str:
|
||||||
|
mail_host = _mail_host(domain)
|
||||||
|
changed = []
|
||||||
|
for name in ("apply-admin-nginx-overrides.py", "sync-traefik-admin-certs.sh"):
|
||||||
|
path = _DEPLOY_SCRIPTS / name
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if mail_host not in text:
|
||||||
|
continue
|
||||||
|
new_text = re.sub(rf'^\s*"{re.escape(mail_host)}".*\n', "", text, flags=re.MULTILINE)
|
||||||
|
new_text = new_text.replace(f" {mail_host}", "")
|
||||||
|
new_text = new_text.replace(f"'{mail_host}'", "")
|
||||||
|
path.write_text(new_text, encoding="utf-8")
|
||||||
|
changed.append(name)
|
||||||
|
return ",".join(changed) if changed else "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_traefik_export_certs(domain: str) -> str:
|
||||||
|
slug = _domain_slug(domain)
|
||||||
|
pattern = f"mail-{slug}*"
|
||||||
|
removed = 0
|
||||||
|
if _TRAEFIK_CERT_EXPORT.is_dir():
|
||||||
|
for f in _TRAEFIK_CERT_EXPORT.glob(pattern):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
removed += 1
|
||||||
|
return str(removed)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_nginx_server_block(text: str, server_name: str) -> tuple[str, bool]:
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"server\s*\{{\s*\n\s*server_name\s+{re.escape(server_name)}\b",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
match = pattern.search(text)
|
||||||
|
if not match:
|
||||||
|
return text, False
|
||||||
|
start = match.start()
|
||||||
|
brace = 0
|
||||||
|
i = text.find("{", start)
|
||||||
|
while i < len(text):
|
||||||
|
if text[i] == "{":
|
||||||
|
brace += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
brace -= 1
|
||||||
|
if brace == 0:
|
||||||
|
end = i + 1
|
||||||
|
while end < len(text) and text[end] in "\r\n":
|
||||||
|
end += 1
|
||||||
|
return text[:start] + text[end:], True
|
||||||
|
i += 1
|
||||||
|
return text, False
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_nginx_vhosts(domain: str, hosts: list[str]) -> str:
|
||||||
|
if not _NGINX_INCLUDES.is_dir():
|
||||||
|
return "skip"
|
||||||
|
targets = [
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.web.https",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.web.admin",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.web.http",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.mail.imap",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.mail.imaps",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.mail.pop3",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.mail.pop3s",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.map.key",
|
||||||
|
_NGINX_INCLUDES / "nginx.conf.map.crt",
|
||||||
|
]
|
||||||
|
edited = 0
|
||||||
|
for fp in targets:
|
||||||
|
if not fp.is_file():
|
||||||
|
continue
|
||||||
|
text = fp.read_text(encoding="utf-8")
|
||||||
|
orig = text
|
||||||
|
for host in hosts:
|
||||||
|
while True:
|
||||||
|
text, removed = _remove_nginx_server_block(text, host)
|
||||||
|
if not removed:
|
||||||
|
break
|
||||||
|
for host in hosts:
|
||||||
|
text = re.sub(rf"^{re.escape(host)} .*\n", "", text, flags=re.MULTILINE)
|
||||||
|
if text != orig:
|
||||||
|
fp.with_suffix(fp.suffix + f".bak-purge-{_domain_slug(domain)}").write_text(orig, encoding="utf-8")
|
||||||
|
fp.write_text(text, encoding="utf-8")
|
||||||
|
edited += 1
|
||||||
|
if edited == 0:
|
||||||
|
return "none"
|
||||||
|
proc = subprocess.run(
|
||||||
|
["/opt/zextras/common/sbin/nginx", "-t", "-c", "/opt/zextras/conf/nginx.conf"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return f"nginx_test_fail:{(proc.stderr or proc.stdout)[:120]}"
|
||||||
|
subprocess.run(
|
||||||
|
["su", "-", "zextras", "-c", "/opt/zextras/common/sbin/nginx -s reload"],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
return f"updated:{edited}"
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_session_logs(domain: str) -> int:
|
||||||
|
count = 0
|
||||||
|
if not _LOG_DIR.is_dir():
|
||||||
|
return 0
|
||||||
|
for f in _LOG_DIR.glob("*.jsonl"):
|
||||||
|
try:
|
||||||
|
if domain in f.read_text(encoding="utf-8", errors="ignore").lower():
|
||||||
|
f.unlink()
|
||||||
|
count += 1
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_purge(
|
||||||
|
domain: str,
|
||||||
|
report: Callable[[str, str, str | None], None] | None = None,
|
||||||
|
*,
|
||||||
|
job_id: str | None = None,
|
||||||
|
requested_by: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
def _step(step_id: str, status: str, detail: str | None = None) -> None:
|
||||||
|
if report:
|
||||||
|
report(step_id, status, detail)
|
||||||
|
if status == "running":
|
||||||
|
activity_log.info(f"PURGE [{step_id}] {detail or '…'}", source="admin")
|
||||||
|
elif status == "done":
|
||||||
|
activity_log.ok(f"PURGE [{step_id}] {detail or 'OK'}", source="admin")
|
||||||
|
elif status == "error":
|
||||||
|
activity_log.error(f"PURGE [{step_id}] {detail or 'erro'}", source="admin")
|
||||||
|
|
||||||
|
rec = domain_registry.get_domain_record(domain) or {}
|
||||||
|
aliases = list(rec.get("mail_aliases") or [])
|
||||||
|
hosts = _collect_purge_hosts(domain, aliases)
|
||||||
|
result: dict[str, Any] = {"domain": domain}
|
||||||
|
traefik_backup: str | None = None
|
||||||
|
|
||||||
|
_step("audit_snapshot", "running")
|
||||||
|
snapshot = build_audit_snapshot(domain, job_id=job_id, requested_by=requested_by)
|
||||||
|
result["audit_snapshot"] = snapshot
|
||||||
|
ac = snapshot.get("carbonio", {}).get("account_count", 0)
|
||||||
|
_step("audit_snapshot", "done", f"{ac} conta(s), inventário gravado")
|
||||||
|
|
||||||
|
_step("carbonio_accounts", "running")
|
||||||
|
result["carbonio_accounts"] = _delete_carbonio_accounts(domain)
|
||||||
|
_step("carbonio_accounts", "done", "; ".join(result["carbonio_accounts"]) or "nenhuma conta")
|
||||||
|
|
||||||
|
_step("carbonio_domain", "running")
|
||||||
|
dd = _delete_carbonio_domain_only(domain)
|
||||||
|
result["carbonio_domain"] = dd or "domínio já ausente"
|
||||||
|
_step("carbonio_domain", "done", result["carbonio_domain"])
|
||||||
|
|
||||||
|
_step("portal_users", "running")
|
||||||
|
result["portal_users_removed"] = _purge_portal_users(domain)
|
||||||
|
_step("portal_users", "done", ", ".join(result["portal_users_removed"]) or "nenhum")
|
||||||
|
|
||||||
|
_step("site_folder", "running")
|
||||||
|
result["site_folder_removed"] = _purge_site_folder(domain)
|
||||||
|
_step("site_folder", "done", "removido" if result["site_folder_removed"] else "já ausente")
|
||||||
|
|
||||||
|
_step("cloudflare", "running")
|
||||||
|
result["cloudflare"] = _purge_cloudflare_zone(domain)
|
||||||
|
_step("cloudflare", "done", result["cloudflare"])
|
||||||
|
|
||||||
|
_step("session_logs", "running")
|
||||||
|
result["session_logs_removed"] = _purge_session_logs(domain)
|
||||||
|
_step("session_logs", "done", str(result["session_logs_removed"]))
|
||||||
|
|
||||||
|
_step("tenant_branding", "running")
|
||||||
|
result["tenant_branding"] = _purge_tenant_branding(domain)
|
||||||
|
_step("tenant_branding", "done", result["tenant_branding"])
|
||||||
|
|
||||||
|
_step("deploy_scripts", "running")
|
||||||
|
result["deploy_scripts"] = _purge_deploy_script_refs(domain)
|
||||||
|
_step("deploy_scripts", "done", result["deploy_scripts"])
|
||||||
|
|
||||||
|
_step("traefik_export_certs", "running")
|
||||||
|
result["traefik_export_certs"] = _purge_traefik_export_certs(domain)
|
||||||
|
_step("traefik_export_certs", "done", result["traefik_export_certs"])
|
||||||
|
|
||||||
|
_step("nginx_vhosts", "running")
|
||||||
|
result["nginx_vhosts"] = _purge_nginx_vhosts(domain, hosts)
|
||||||
|
nginx_ok = not str(result["nginx_vhosts"]).startswith("nginx_test_fail")
|
||||||
|
_step("nginx_vhosts", "done" if nginx_ok else "error", result["nginx_vhosts"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
_step("traefik_sni", "running")
|
||||||
|
result["traefik_sni"] = purge_traefik_sni(hosts)
|
||||||
|
sni_ok = not str(result["traefik_sni"]).startswith("sni_fail")
|
||||||
|
_step("traefik_sni", "done" if sni_ok else "error", result["traefik_sni"])
|
||||||
|
|
||||||
|
_step("traefik_routers", "running")
|
||||||
|
tr = purge_traefik_routers(domain, hosts)
|
||||||
|
result["traefik_routers"] = tr.get("detail") or ("traefik_ok" if tr.get("ok") else "traefik_fail")
|
||||||
|
traefik_backup = tr.get("backup")
|
||||||
|
result["traefik_backup"] = traefik_backup
|
||||||
|
tr_ok = bool(tr.get("ok"))
|
||||||
|
_step("traefik_routers", "done" if tr_ok else "error", result["traefik_routers"])
|
||||||
|
|
||||||
|
_step("traefik_validate", "running")
|
||||||
|
validation = validate_traefik_after_purge(domain)
|
||||||
|
result["traefik_validate"] = validation
|
||||||
|
val_detail = json.dumps(validation.get("checks") or {}, ensure_ascii=False)[:400]
|
||||||
|
val_ok = bool(validation.get("ok"))
|
||||||
|
if not val_ok and traefik_backup:
|
||||||
|
rb = rollback_traefik_backup(traefik_backup)
|
||||||
|
result["traefik_rollback"] = rb
|
||||||
|
validation = validate_traefik_after_purge(domain)
|
||||||
|
result["traefik_validate_after_rollback"] = validation
|
||||||
|
_step("traefik_validate", "done" if val_ok else "error", val_detail)
|
||||||
|
|
||||||
|
if not sni_ok or not tr_ok or not val_ok:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Traefik purge incompleto (sni={result['traefik_sni']}, "
|
||||||
|
f"routers={result['traefik_routers']}, validate={val_detail})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
result["traefik_error"] = str(e)
|
||||||
|
if report:
|
||||||
|
_step("traefik_validate", "error", str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
_step("openpanel_dns", "running")
|
||||||
|
op = purge_openpanel(domain)
|
||||||
|
result["openpanel"] = op
|
||||||
|
_step("openpanel_dns", "done", op.get("dns", "ok"))
|
||||||
|
_step("openpanel_user", "running")
|
||||||
|
_step("openpanel_user", "done", op.get("user", "ok"))
|
||||||
|
|
||||||
|
_step("pmg_domain", "running")
|
||||||
|
pmg = purge_pmg(domain)
|
||||||
|
result["pmg"] = pmg
|
||||||
|
_step("pmg_domain", "done", pmg.get("domains", "ok"))
|
||||||
|
_step("pmg_transport", "running")
|
||||||
|
_step("pmg_transport", "done", pmg.get("transport", "ok"))
|
||||||
|
|
||||||
|
result["carbonio"] = result.get("carbonio_accounts", []) + (
|
||||||
|
[result["carbonio_domain"]] if result.get("carbonio_domain") else []
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run_purge_job(job_id: str) -> None:
|
||||||
|
job = purge_jobs.get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
domain = job["domain"]
|
||||||
|
report = purge_jobs.make_reporter(job_id)
|
||||||
|
try:
|
||||||
|
if domain in _PURGE_BLOCKLIST:
|
||||||
|
raise ValueError(f"Domínio {domain} está na blocklist de purge")
|
||||||
|
activity_log.info(f"PURGE job {job_id} iniciado: {domain}", source="admin")
|
||||||
|
result = _execute_purge(domain, report=report, job_id=job_id)
|
||||||
|
purge_jobs.complete_job(job_id, result)
|
||||||
|
carbonio.invalidate_domain_list_cache()
|
||||||
|
carbonio_cache.invalidate_domain(domain)
|
||||||
|
activity_log.ok(f"PURGE job {job_id} concluído: {domain}", source="admin")
|
||||||
|
except Exception as e:
|
||||||
|
purge_jobs.fail_job(job_id, str(e))
|
||||||
|
activity_log.error(f"PURGE job {job_id} falhou: {e}", source="admin")
|
||||||
|
|
||||||
|
|
||||||
|
def assert_purge_allowed(domain: str) -> None:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if domain in _PURGE_BLOCKLIST:
|
||||||
|
raise ValueError(f"Domínio {domain} está na blocklist de purge")
|
||||||
|
|
||||||
|
|
||||||
|
def purge_domain(domain: str) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
assert_purge_allowed(domain)
|
||||||
|
activity_log.info(f"PURGE iniciado: {domain}", source="admin")
|
||||||
|
job = purge_jobs.create_job(domain)
|
||||||
|
job_id = job["job_id"]
|
||||||
|
report = purge_jobs.make_reporter(job_id)
|
||||||
|
try:
|
||||||
|
result = _execute_purge(domain, report=report)
|
||||||
|
purge_jobs.complete_job(job_id, result)
|
||||||
|
carbonio.invalidate_domain_list_cache()
|
||||||
|
carbonio_cache.invalidate_domain(domain)
|
||||||
|
activity_log.ok(f"PURGE concluído: {domain}", source="admin")
|
||||||
|
job_data = purge_jobs.get_job(job_id) or {}
|
||||||
|
steps = purge_jobs.steps_for_desk(job_data.get("steps") or [])
|
||||||
|
return {"domain": domain, "steps": steps, **result}
|
||||||
|
except Exception as e:
|
||||||
|
purge_jobs.fail_job(job_id, str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_carbonio_account(email: str) -> dict:
|
||||||
|
"""Remove uma conta Carbonio (zmprov da) — Spec 022."""
|
||||||
|
email = email.lower().strip()
|
||||||
|
if "@" not in email:
|
||||||
|
raise ValueError("e-mail inválido")
|
||||||
|
domain = email.split("@", 1)[1]
|
||||||
|
assert_purge_allowed(domain)
|
||||||
|
if not carbonio.account_exists(email):
|
||||||
|
return {"ok": True, "email": email, "message": "Conta já não existia", "skipped": True}
|
||||||
|
code, out, err = carbonio._zmprov_run("da", email, log_cmd=True)
|
||||||
|
if code != 0 and not carbonio._is_missing_account(err, out):
|
||||||
|
raise carbonio.CarbonioError(err or out or f"zmprov da falhou para {email}")
|
||||||
|
carbonio.invalidate_domain_cache(domain)
|
||||||
|
return {"ok": True, "email": email, "message": f"Conta {email} removida do Carbonio", "rc": code}
|
||||||
380
deploy/vm112-wizard/perf-domains-list-20260625/infrastructure.py
Normal file
380
deploy/vm112-wizard/perf-domains-list-20260625/infrastructure.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""Provisionamento infra: HAProxy SNI (CT114), Traefik, certificado LE, Carbonio."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services import activity_log, carbonio, dns_verify, carbonio_cache
|
||||||
|
from app.services.domain_registry import get_domain_record
|
||||||
|
from app.services.mail_aliases import sanitize_mail_aliases
|
||||||
|
|
||||||
|
CERT_NAME = "mail-vm112-multi"
|
||||||
|
TRAEFIK_SERVICE = "dratcoin-Service"
|
||||||
|
SNI_MAP = "/root/traefik/haproxy-mail-sni/maps/sni_vm112.lst"
|
||||||
|
TRAEFIK_DYNAMIC = "/root/traefik/dynamic.yml"
|
||||||
|
DEPLOY_SCRIPT = "/usr/local/bin/carbonio-cert-deploy.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_host(domain: str) -> str:
|
||||||
|
return f"mail.{domain.lower().strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _router_key_for_host(mail_host: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", mail_host.lower()).strip("-")
|
||||||
|
return f"mail-{slug}-Router"
|
||||||
|
|
||||||
|
|
||||||
|
def _router_key(domain: str) -> str:
|
||||||
|
return _router_key_for_host(_mail_host(domain))
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_mail_hosts(domain: str, mail_aliases: list[str] | None = None) -> list[str]:
|
||||||
|
primary = _mail_host(domain)
|
||||||
|
hosts = [primary]
|
||||||
|
for h in mail_aliases or []:
|
||||||
|
hh = h.lower().strip().rstrip(".")
|
||||||
|
if hh and hh not in hosts:
|
||||||
|
hosts.append(hh)
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def _cert_san_hostnames(all_hosts: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Hostnames para certificado LE — só webmail (mail.* / webmail.*).
|
||||||
|
Exclui portal.* e outros aliases que não servem Carbonio directamente.
|
||||||
|
"""
|
||||||
|
cert: list[str] = []
|
||||||
|
for h in all_hosts:
|
||||||
|
hl = h.lower().strip()
|
||||||
|
if hl.startswith("portal."):
|
||||||
|
continue
|
||||||
|
if hl.startswith("mail.") or hl.startswith("webmail."):
|
||||||
|
cert.append(hl)
|
||||||
|
return sorted(set(cert))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_cert_sans(existing_sans: list[str], cert_hosts: list[str]) -> list[str]:
|
||||||
|
"""Mantém SANs activos no certificado multi-domínio."""
|
||||||
|
result = set(cert_hosts)
|
||||||
|
for san in existing_sans:
|
||||||
|
s = san.lower().strip()
|
||||||
|
if not (s.startswith("mail.") or s.startswith("webmail.")):
|
||||||
|
continue
|
||||||
|
if s.startswith("portal."):
|
||||||
|
continue
|
||||||
|
result.add(s)
|
||||||
|
return sorted(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_ct114(command: str, timeout: int = 90) -> tuple[bool, str]:
|
||||||
|
host = settings.traefik_ssh_host
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
"ssh",
|
||||||
|
"-o",
|
||||||
|
"BatchMode=yes",
|
||||||
|
"-o",
|
||||||
|
"StrictHostKeyChecking=accept-new",
|
||||||
|
host,
|
||||||
|
command,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
out = (proc.stdout or "") + (proc.stderr or "")
|
||||||
|
return proc.returncode == 0, out.strip()
|
||||||
|
except (subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ct114_file(path: str) -> tuple[bool, str]:
|
||||||
|
ok, out = _ssh_ct114(f"cat {path} 2>/dev/null || true")
|
||||||
|
return ok, out
|
||||||
|
|
||||||
|
|
||||||
|
def _cert_domains() -> list[str]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["certbot", "certificates"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
text = proc.stdout or ""
|
||||||
|
for block in text.split("Certificate Name:"):
|
||||||
|
if CERT_NAME in block:
|
||||||
|
m = re.search(r"Domains:\s*(.+)", block)
|
||||||
|
if m:
|
||||||
|
return m.group(1).split()
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(domain: str, mail_aliases: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
cache_key = f"infra_status:{domain}"
|
||||||
|
cached = carbonio_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return dict(cached)
|
||||||
|
mail = _mail_host(domain)
|
||||||
|
if mail_aliases is not None:
|
||||||
|
aliases = sanitize_mail_aliases(mail_aliases, domain)
|
||||||
|
else:
|
||||||
|
rec = get_domain_record(domain)
|
||||||
|
aliases = list(rec.get("mail_aliases") or []) if rec else []
|
||||||
|
all_hosts = _collect_mail_hosts(domain, aliases)
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# 1 Carbonio
|
||||||
|
c_ok = carbonio.domain_exists(domain)
|
||||||
|
c_msg = (
|
||||||
|
"Domínio activo no Carbonio"
|
||||||
|
if c_ok
|
||||||
|
else "Aguarda «Criar conta agora» (zmprov cd + ca)"
|
||||||
|
)
|
||||||
|
steps.append({"id": "carbonio_domain", "label": "Carbonio (domínio)", "ok": c_ok, "message": c_msg})
|
||||||
|
|
||||||
|
# 2 DNS público
|
||||||
|
try:
|
||||||
|
v = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
|
||||||
|
d_ok = bool(v.get("ready"))
|
||||||
|
d_msg = "MX e A mail OK" if d_ok else "DNS mail incompleto"
|
||||||
|
except Exception as e:
|
||||||
|
d_ok = False
|
||||||
|
d_msg = str(e)
|
||||||
|
steps.append({"id": "dns_mail", "label": "DNS público (MX/A)", "ok": d_ok, "message": d_msg})
|
||||||
|
|
||||||
|
# 3 HAProxy SNI
|
||||||
|
ok_ssh, sni_body = _read_ct114_file(SNI_MAP)
|
||||||
|
if not ok_ssh:
|
||||||
|
s_ok, s_msg = False, f"SSH CT114 indisponível: {sni_body[:120]}"
|
||||||
|
else:
|
||||||
|
sni_lines = {ln.strip() for ln in sni_body.splitlines() if ln.strip()}
|
||||||
|
missing = [h for h in all_hosts if h not in sni_lines]
|
||||||
|
s_ok = len(missing) == 0
|
||||||
|
s_msg = (
|
||||||
|
f"{len(all_hosts)} hostname(s) no SNI"
|
||||||
|
if s_ok
|
||||||
|
else f"Faltam no SNI: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
steps.append({"id": "haproxy_sni", "label": "HAProxy SNI (CT114)", "ok": s_ok, "message": s_msg})
|
||||||
|
|
||||||
|
# 4 Traefik router
|
||||||
|
ok_ssh, dyn = _read_ct114_file(TRAEFIK_DYNAMIC)
|
||||||
|
if not ok_ssh:
|
||||||
|
t_ok, t_msg = False, f"SSH CT114: {dyn[:120]}"
|
||||||
|
else:
|
||||||
|
missing_t = [h for h in all_hosts if f"Host(`{h}`)" not in dyn]
|
||||||
|
t_ok = len(missing_t) == 0
|
||||||
|
t_msg = (
|
||||||
|
f"Routers Traefik ({len(all_hosts)} hostnames)"
|
||||||
|
if t_ok
|
||||||
|
else f"Faltam routers: {', '.join(missing_t)}"
|
||||||
|
)
|
||||||
|
steps.append({"id": "traefik_router", "label": "Traefik HTTPS (CT114)", "ok": t_ok, "message": t_msg})
|
||||||
|
|
||||||
|
# 5 Cert SAN (só hostnames mail.* — não portal.*)
|
||||||
|
cert_hosts = _cert_san_hostnames(all_hosts)
|
||||||
|
sans = _cert_domains()
|
||||||
|
missing_cert = [h for h in cert_hosts if h not in sans]
|
||||||
|
cert_ok = len(missing_cert) == 0
|
||||||
|
steps.append(
|
||||||
|
{
|
||||||
|
"id": "cert_san",
|
||||||
|
"label": "Certificado LE (VM112)",
|
||||||
|
"ok": cert_ok,
|
||||||
|
"message": (
|
||||||
|
f"SAN inclui {len(cert_hosts)} hostname(s) webmail"
|
||||||
|
if cert_ok
|
||||||
|
else f"Faltam no cert: {', '.join(missing_cert)}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6 Webmail HTTPS
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=12.0, verify=False) as client:
|
||||||
|
r = client.get(f"https://{mail}/", follow_redirects=True)
|
||||||
|
w_ok = r.status_code < 500
|
||||||
|
w_msg = f"HTTPS {r.status_code}" if w_ok else f"HTTPS erro {r.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
w_ok = False
|
||||||
|
w_msg = str(e)[:80]
|
||||||
|
steps.append({"id": "webmail_https", "label": "Webmail HTTPS", "ok": w_ok, "message": w_msg})
|
||||||
|
|
||||||
|
all_ok = all(s["ok"] for s in steps)
|
||||||
|
result = {
|
||||||
|
"domain": domain,
|
||||||
|
"mail_host": mail,
|
||||||
|
"mail_aliases": aliases,
|
||||||
|
"mail_hosts": all_hosts,
|
||||||
|
"steps": steps,
|
||||||
|
"ready": all_ok,
|
||||||
|
}
|
||||||
|
carbonio_cache.set(cache_key, result, carbonio_cache.TTL_INFRA_STATUS)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def provision(
|
||||||
|
domain: str,
|
||||||
|
step_id: str | None = None,
|
||||||
|
mail_aliases: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
if mail_aliases is not None:
|
||||||
|
aliases = sanitize_mail_aliases(mail_aliases, domain)
|
||||||
|
else:
|
||||||
|
rec = get_domain_record(domain)
|
||||||
|
aliases = list(rec.get("mail_aliases") or []) if rec else []
|
||||||
|
all_hosts = _collect_mail_hosts(domain, aliases)
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def run(step: str, fn) -> None:
|
||||||
|
if step_id and step_id != step:
|
||||||
|
return
|
||||||
|
activity_log.info(f"Infra: iniciar {step}", source="traefik")
|
||||||
|
try:
|
||||||
|
msg = fn()
|
||||||
|
activity_log.ok(f"Infra {step}: {msg}", source="traefik")
|
||||||
|
results.append({"id": step, "ok": True, "message": msg})
|
||||||
|
except Exception as e:
|
||||||
|
activity_log.error(f"Infra {step}: {e}", source="traefik")
|
||||||
|
results.append({"id": step, "ok": False, "message": str(e)})
|
||||||
|
|
||||||
|
def do_sni() -> str:
|
||||||
|
ok, body = _read_ct114_file(SNI_MAP)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(body)
|
||||||
|
added: list[str] = []
|
||||||
|
for host in all_hosts:
|
||||||
|
if host in body.splitlines():
|
||||||
|
continue
|
||||||
|
cmd = f"grep -qxF '{host}' {SNI_MAP} || echo '{host}' >> {SNI_MAP}"
|
||||||
|
ok2, out = _ssh_ct114(cmd, timeout=60)
|
||||||
|
if not ok2:
|
||||||
|
raise RuntimeError(out)
|
||||||
|
added.append(host)
|
||||||
|
if added:
|
||||||
|
ok3, out = _ssh_ct114(
|
||||||
|
"cd /root/traefik && docker compose restart mail-sni-proxy 2>&1 | tail -2",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if not ok3:
|
||||||
|
raise RuntimeError(out)
|
||||||
|
return f"SNI: {len(all_hosts)} hostname(s)" + (f" (+{len(added)} novos)" if added else " (já OK)")
|
||||||
|
|
||||||
|
def do_traefik() -> str:
|
||||||
|
ok, body = _read_ct114_file(TRAEFIK_DYNAMIC)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(body)
|
||||||
|
added: list[str] = []
|
||||||
|
for host in all_hosts:
|
||||||
|
if f"Host(`{host}`)" in body:
|
||||||
|
continue
|
||||||
|
r_key = _router_key_for_host(host)
|
||||||
|
remote = f"""python3 <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path("{TRAEFIK_DYNAMIC}")
|
||||||
|
text = p.read_text()
|
||||||
|
mail = "{host}"
|
||||||
|
rkey = "{r_key}"
|
||||||
|
svc = "{TRAEFIK_SERVICE}"
|
||||||
|
if f"Host(`{{mail}}`)" in text:
|
||||||
|
print("exists")
|
||||||
|
else:
|
||||||
|
block = f'''
|
||||||
|
{{rkey}}:
|
||||||
|
rule: Host(`{{mail}}`)
|
||||||
|
service: {{svc}}
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
middlewares:
|
||||||
|
- default-headers
|
||||||
|
'''
|
||||||
|
idx = text.find(" services:")
|
||||||
|
if idx < 0:
|
||||||
|
raise SystemExit("services: não encontrado")
|
||||||
|
p.write_text(text[:idx] + block + text[idx:])
|
||||||
|
print("added")
|
||||||
|
PY"""
|
||||||
|
ok2, out = _ssh_ct114(remote, timeout=90)
|
||||||
|
if not ok2:
|
||||||
|
raise RuntimeError(out)
|
||||||
|
added.append(host)
|
||||||
|
if added:
|
||||||
|
ok3, out2 = _ssh_ct114(
|
||||||
|
"cd /root/traefik && docker compose restart traefik 2>&1 | tail -3",
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if not ok3:
|
||||||
|
raise RuntimeError(out2)
|
||||||
|
return f"Traefik: {len(all_hosts)} hostname(s)" + (f" (+{len(added)} routers)" if added else " (já OK)")
|
||||||
|
|
||||||
|
def do_cert() -> str:
|
||||||
|
cert_hosts = _cert_san_hostnames(all_hosts)
|
||||||
|
sans = _cert_domains()
|
||||||
|
missing = [h for h in cert_hosts if h not in sans]
|
||||||
|
if not missing:
|
||||||
|
return f"SAN já inclui {len(cert_hosts)} hostname(s) webmail"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.services.cloudflare import CloudflareDNS
|
||||||
|
|
||||||
|
zone = CloudflareDNS().get_zone_by_name(domain)
|
||||||
|
if zone and zone.get("status") != "active":
|
||||||
|
ns = ", ".join(zone.get("name_servers") or [])
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Zona Cloudflare «{domain}» ainda não activa (status: {zone.get('status')}). "
|
||||||
|
f"Altere os nameservers no registrador para: {ns}"
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_domains = _normalize_cert_sans(sans, cert_hosts)
|
||||||
|
dom_args = " ".join(f"-d {d}" for d in new_domains)
|
||||||
|
creds = settings.certbot_cloudflare_credentials
|
||||||
|
cmd = (
|
||||||
|
f"certbot certonly --dns-cloudflare "
|
||||||
|
f"--dns-cloudflare-credentials {creds} "
|
||||||
|
f"--dns-cloudflare-propagation-seconds 90 "
|
||||||
|
f"--cert-name {CERT_NAME} --expand {dom_args} "
|
||||||
|
f"--non-interactive --agree-tos"
|
||||||
|
)
|
||||||
|
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=600)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
err = (proc.stderr or proc.stdout or "certbot falhou")[:500]
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{err} — confirme NS Cloudflare do domínio e aguarde propagação DNS."
|
||||||
|
)
|
||||||
|
lineage = f"/etc/letsencrypt/live/{CERT_NAME}"
|
||||||
|
deploy = subprocess.run(
|
||||||
|
f"RENEWED_LINEAGE={lineage} {DEPLOY_SCRIPT}",
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
if deploy.returncode != 0:
|
||||||
|
raise RuntimeError((deploy.stderr or deploy.stdout or "deploy falhou")[:300])
|
||||||
|
return f"certificado expandido ({len(new_domains)} SANs) e deploy Carbonio"
|
||||||
|
|
||||||
|
run("haproxy_sni", do_sni)
|
||||||
|
run("traefik_router", do_traefik)
|
||||||
|
if step_id in (None, "cert_san", "carbonio_cert"):
|
||||||
|
if not step_id or step_id == "cert_san":
|
||||||
|
run("cert_san", do_cert)
|
||||||
|
|
||||||
|
status = get_status(domain, aliases)
|
||||||
|
return {"domain": domain, "results": results, "status": status, "mail_aliases": aliases}
|
||||||
|
|
@ -153,3 +153,13 @@ docker-compose -f docker-compose.mvp.yml up -d api frontend
|
||||||
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
|
||||||
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
- Modelo comercial Pizza as a Service documentado na Spec 018.
|
||||||
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
- Purge Spec 017 mantido; UI evolui (drawer + jobs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Continuação — 2026-06-25 (performance + UX Escopo OPS)
|
||||||
|
|
||||||
|
Ver documento dedicado:
|
||||||
|
|
||||||
|
**`docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md`**
|
||||||
|
|
||||||
|
Inclui: cache VM112/VM122, loading modal purge, cards Escopo OPS clicáveis, blocklist UI, versão frontend `20260625align1`.
|
||||||
|
|
|
||||||
124
docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
124
docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
# Anais de Referência — Serviços IaaS: Performance, UX Escopo OPS, Blocklist
|
||||||
|
|
||||||
|
**Data:** 2026-06-25
|
||||||
|
**Utilizador:** Roger
|
||||||
|
**Sistema:** Desk VM122 (`10.10.10.122`) · Wizard VM112 (`10.10.10.112`)
|
||||||
|
**Specs:** 017 · 018 · 026 · 028 · 032 · 037
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Resumo executivo
|
||||||
|
|
||||||
|
Sessão de melhorias na página **Serviços IaaS** (Desk):
|
||||||
|
|
||||||
|
1. **Performance** — cache VM112 lista + detalhe domínio; cache proxy VM122; SWR frontend.
|
||||||
|
2. **Modal purge** — skeleton imediato + loading animado (barra, etapas, spinner).
|
||||||
|
3. **Escopo OPS** — cards clicáveis com camada (tenant/app/infra), Spec e navegação.
|
||||||
|
4. **Blocklist UI** — `ligbox.com.br` e `itecnologys.com` visíveis na lista e painel Escopo.
|
||||||
|
5. **Alinhamento lista clientes** — badges agrupados (fix desalinhamento `itecnologys.com`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Performance — resultados
|
||||||
|
|
||||||
|
| Pedido | Antes | Depois (cache quente) |
|
||||||
|
|--------|-------|------------------------|
|
||||||
|
| VM112 `GET /api/admin/domains` | ~5–11s | **~0,03s** |
|
||||||
|
| VM112 `GET /api/admin/domains/{domain}` | ~15–20s | **~0,04s** |
|
||||||
|
| Desk `GET /api/v1/vm112/domains` | ~16s | **~0,08s** |
|
||||||
|
| Desk detalhe domínio (2.º hit) | ~15s+ | **~0,1s** |
|
||||||
|
| Modal purge 1.º hit (cold) | ecrã branco | skeleton + ~10–15s |
|
||||||
|
|
||||||
|
**Patch VM112:** `deploy/vm112-wizard/perf-domains-list-20260625/`
|
||||||
|
**Desk API:** `VM112_DOMAINS_CACHE_TTL=60`, `VM112_DOMAIN_DETAIL_TTL=45`
|
||||||
|
**Poll Serviços:** 90s (`app.js`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UX Escopo OPS — mapa cards
|
||||||
|
|
||||||
|
| Card | Camada | Spec | Destino |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| Carbonio | Aplicação MOSP | 017 | Modal Serviços |
|
||||||
|
| ligbox-sites | Tenant utilizador | 017 | Modal Serviços |
|
||||||
|
| Portal users | Tenant utilizador | 028 | Console `/admin/dominio` |
|
||||||
|
| Cloudflare | Infraestrutura | 037 | Console DNS Viewer |
|
||||||
|
| Traefik / SNI | Infraestrutura | 026 | Desk Infra CODE |
|
||||||
|
| Desk OPS | Aplicação MOSP | 017 | Eventos › Histórico purges |
|
||||||
|
|
||||||
|
**Helper navegação:** `window.DeskNavigate.go(view, { eventsTab })` — `app.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Blocklist purge
|
||||||
|
|
||||||
|
| Domínio | Purge API | UI |
|
||||||
|
|---------|-----------|-----|
|
||||||
|
| `ligbox.com.br` | HTTP 400 | 🔒 badge + banner Escopo + modal desactivado |
|
||||||
|
| `itecnologys.com` | HTTP 400 | idem |
|
||||||
|
|
||||||
|
Constante: `PURGE_BLOCKLIST` — `vm112_domains.py` + espelho `servicos.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ficheiros principais
|
||||||
|
|
||||||
|
| Ficheiro | Função |
|
||||||
|
|----------|--------|
|
||||||
|
| `frontend/assets/servicos.js` | Escopo OPS, loading modal, blocklist, cache SWR |
|
||||||
|
| `frontend/assets/styles.css` | `.vm112-load-*`, `.servicos-scope-*`, client row grid |
|
||||||
|
| `frontend/assets/app.js` | `DeskNavigate`, poll 90s |
|
||||||
|
| `frontend/assets/auth.js` | sessão resiliente rede |
|
||||||
|
| `frontend/index.html` | cache bust `20260625align1` |
|
||||||
|
| `api/app/vm112_domains.py` | cache proxy, `purge_blocked` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deploy VM122 (referência)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshpass -p '805353' scp servicos.js styles.css app.js index.html root@10.10.10.122:/tmp/
|
||||||
|
ssh root@10.10.10.122 '
|
||||||
|
C=ligbox-ops-platform_frontend_1
|
||||||
|
docker cp /tmp/servicos.js $C:/usr/share/nginx/html/assets/servicos.js
|
||||||
|
docker cp /tmp/styles.css $C:/usr/share/nginx/html/assets/styles.css
|
||||||
|
docker cp /tmp/app.js $C:/usr/share/nginx/html/assets/app.js
|
||||||
|
docker cp /tmp/index.html $C:/usr/share/nginx/html/index.html
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hard refresh:** Ctrl+Shift+R em `https://desk.ligbox.com.br`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Versão frontend (cache bust)
|
||||||
|
|
||||||
|
| Asset | Query string |
|
||||||
|
|-------|----------------|
|
||||||
|
| `styles.css` | `?v=20260625align1` |
|
||||||
|
| `servicos.js` | `?v=20260625align1` |
|
||||||
|
| `app.js` | `?v=20260625align1` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Specs actualizadas
|
||||||
|
|
||||||
|
| Spec | Secção |
|
||||||
|
|------|--------|
|
||||||
|
| 017 | Performance cache, UI loading/blocklist/Escopo |
|
||||||
|
| 018 | Performance, Escopo OPS cards, critérios aceite |
|
||||||
|
| 032 | UI blocklist |
|
||||||
|
| 026 | Destino card Traefik |
|
||||||
|
| 028 | Destino card Portal |
|
||||||
|
| 037 | Destino card Cloudflare |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Canais de arquivo
|
||||||
|
|
||||||
|
| Canal | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Anais | `docs/anais-referencia/20260625_SERVICOS_IAAS_PERF_UX_ESCOPO.md` |
|
||||||
|
| Spec 017 | `specs/017-vm112-domain-orchestration/spec.md` |
|
||||||
|
| Spec 018 | `specs/018-service-orchestration/spec.md` |
|
||||||
|
| Patch VM112 | `deploy/vm112-wizard/perf-domains-list-20260625/` |
|
||||||
|
|
@ -126,8 +126,19 @@ ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.as
|
||||||
|
|
||||||
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
|
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
|
||||||
|
|
||||||
|
_cors_raw = os.getenv(
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"https://console.ligbox.com.br,https://desk.ligbox.com.br,http://localhost:5173,http://127.0.0.1:5173",
|
||||||
|
)
|
||||||
|
_cors_origins = [o.strip() for o in _cors_raw.split(",") if o.strip()]
|
||||||
|
|
||||||
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic")
|
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=_cors_origins or ["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(registration_router)
|
app.include_router(registration_router)
|
||||||
app.include_router(mfa_recovery_router)
|
app.include_router(mfa_recovery_router)
|
||||||
|
|
@ -1067,6 +1078,24 @@ def list_webhook_events(
|
||||||
return {"events": [_enrich_event(r) for r in rows[:50]]}
|
return {"events": [_enrich_event(r) for r in rows[:50]]}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/webhooks/events/{event_id}")
|
||||||
|
def get_webhook_event(
|
||||||
|
event_id: int,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
"""Detalhe de um evento webhook — usado pelo Auditor de Eventos (Spec 017)."""
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, event_type, source, payload, created_at FROM webhook_events WHERE id = ?",
|
||||||
|
(event_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Evento não encontrado")
|
||||||
|
if not can_list_webhook_events(user.role, row["source"]):
|
||||||
|
raise HTTPException(403, "insufficient permissions")
|
||||||
|
return {"event": _enrich_event(row)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/onboard/funnel")
|
@app.get("/api/v1/onboard/funnel")
|
||||||
def onboard_funnel(
|
def onboard_funnel(
|
||||||
window_hours: int = Query(default=48, ge=1, le=168),
|
window_hours: int = Query(default=48, ge=1, le=168),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from threading import Lock
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -13,6 +15,16 @@ from app import auth
|
||||||
|
|
||||||
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
||||||
VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-change-later")
|
VM112_ADMIN_API_KEY = os.getenv("VM112_ADMIN_API_KEY", "ibytera-corp-api-key-change-later")
|
||||||
|
# Purge Carbonio/CF/Traefik pode demorar vários minutos — evitar httpx "timed out" prematuro.
|
||||||
|
VM112_PURGE_HTTP_TIMEOUT = float(os.getenv("VM112_PURGE_HTTP_TIMEOUT", "300"))
|
||||||
|
VM112_LIST_TIMEOUT = float(os.getenv("VM112_LIST_TIMEOUT", "30"))
|
||||||
|
VM112_DOMAINS_CACHE_TTL = float(os.getenv("VM112_DOMAINS_CACHE_TTL", "60"))
|
||||||
|
VM112_DOMAIN_DETAIL_TTL = float(os.getenv("VM112_DOMAIN_DETAIL_TTL", "45"))
|
||||||
|
|
||||||
|
_DOMAINS_CACHE: dict[str, Any] | None = None
|
||||||
|
_DOMAINS_CACHE_AT = 0.0
|
||||||
|
_DOMAINS_CACHE_LOCK = Lock()
|
||||||
|
_DOMAIN_DETAIL_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
|
||||||
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
PURGE_BLOCKLIST = frozenset({"ligbox.com.br", "itecnologys.com"})
|
||||||
|
|
||||||
|
|
@ -29,6 +41,21 @@ VM112_PURGE_STEP_LABELS = (
|
||||||
"Logs de sessão wizard",
|
"Logs de sessão wizard",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Origem da solicitação de expurgo (user = JWT / sessão; source = canal)
|
||||||
|
PURGE_SOURCE_LABELS: dict[str, str] = {
|
||||||
|
"desk.ui.purge": "Desk › Purge domínio (UI)",
|
||||||
|
"desk.api.purge": "Desk › Purge domínio (API sync)",
|
||||||
|
"desk.api.purge-jobs": "Desk › Purge domínio (job async)",
|
||||||
|
"desk.api.purge-stream": "Desk › Purge domínio (SSE)",
|
||||||
|
"cursor.agent": "Cursor Agent (comando chat)",
|
||||||
|
"domain-console.sandbox": "Domain Console › sandbox",
|
||||||
|
"desk.agentic": "Desk › rotina agentic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def purge_source_label(source: str) -> str:
|
||||||
|
return PURGE_SOURCE_LABELS.get(source, source or "desconhecido")
|
||||||
|
|
||||||
|
|
||||||
def _ts() -> str:
|
def _ts() -> str:
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
@ -80,26 +107,46 @@ def delete_carbonio_account(email: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_domains(query: str = "") -> dict[str, Any]:
|
def list_domains(query: str = "", *, force_refresh: bool = False) -> dict[str, Any]:
|
||||||
with httpx.Client(timeout=60.0) as client:
|
"""Lista domínios VM112 — cache TTL curto (Serviços IaaS poll frequente)."""
|
||||||
|
global _DOMAINS_CACHE, _DOMAINS_CACHE_AT
|
||||||
|
if not query and not force_refresh:
|
||||||
|
with _DOMAINS_CACHE_LOCK:
|
||||||
|
if _DOMAINS_CACHE is not None and time.time() - _DOMAINS_CACHE_AT < VM112_DOMAINS_CACHE_TTL:
|
||||||
|
return {**_DOMAINS_CACHE, "cached": True, "cache_age_sec": int(time.time() - _DOMAINS_CACHE_AT)}
|
||||||
|
|
||||||
|
with httpx.Client(timeout=VM112_LIST_TIMEOUT) as client:
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{VM112_API}/api/admin/domains",
|
f"{VM112_API}/api/admin/domains",
|
||||||
params={"q": query} if query else None,
|
params={"q": query} if query else None,
|
||||||
headers=_vm112_headers(),
|
headers=_vm112_headers(),
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
data = r.json()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
with _DOMAINS_CACHE_LOCK:
|
||||||
|
_DOMAINS_CACHE = data
|
||||||
|
_DOMAINS_CACHE_AT = time.time()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def get_domain(domain: str) -> dict[str, Any]:
|
def get_domain(domain: str) -> dict[str, Any]:
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
with httpx.Client(timeout=180.0) as client:
|
now = time.time()
|
||||||
r = client.get(
|
cached = _DOMAIN_DETAIL_CACHE.get(domain)
|
||||||
f"{VM112_API}/api/admin/domains/{domain}",
|
if cached and now - cached[0] < VM112_DOMAIN_DETAIL_TTL:
|
||||||
headers=_vm112_headers(),
|
data = dict(cached[1])
|
||||||
)
|
else:
|
||||||
r.raise_for_status()
|
with httpx.Client(timeout=VM112_LIST_TIMEOUT) as client:
|
||||||
data = r.json()
|
r = client.get(
|
||||||
|
f"{VM112_API}/api/admin/domains/{domain}",
|
||||||
|
headers=_vm112_headers(),
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
_DOMAIN_DETAIL_CACHE[domain] = (now, data)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
data["purge_extra_auth_required"] = requires_purge_extra_auth(domain)
|
data["purge_extra_auth_required"] = requires_purge_extra_auth(domain)
|
||||||
data["purge_blocked"] = domain in PURGE_BLOCKLIST
|
data["purge_blocked"] = domain in PURGE_BLOCKLIST
|
||||||
|
|
@ -129,7 +176,7 @@ def domain_exists_on_vm112(domain: str) -> bool:
|
||||||
def start_purge_vm112(domain: str) -> dict[str, Any]:
|
def start_purge_vm112(domain: str) -> dict[str, Any]:
|
||||||
"""Inicia purge assíncrono na VM112 (Spec 017 Fase 3)."""
|
"""Inicia purge assíncrono na VM112 (Spec 017 Fase 3)."""
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
with httpx.Client(timeout=60.0) as client:
|
with httpx.Client(timeout=VM112_PURGE_HTTP_TIMEOUT) as client:
|
||||||
r = client.post(
|
r = client.post(
|
||||||
f"{VM112_API}/api/admin/domains/{domain}/purge",
|
f"{VM112_API}/api/admin/domains/{domain}/purge",
|
||||||
headers=_vm112_headers(),
|
headers=_vm112_headers(),
|
||||||
|
|
@ -139,7 +186,7 @@ def start_purge_vm112(domain: str) -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def poll_purge_vm112_job(job_id: str) -> dict[str, Any]:
|
def poll_purge_vm112_job(job_id: str) -> dict[str, Any]:
|
||||||
with httpx.Client(timeout=60.0) as client:
|
with httpx.Client(timeout=VM112_PURGE_HTTP_TIMEOUT) as client:
|
||||||
r = client.get(
|
r = client.get(
|
||||||
f"{VM112_API}/api/admin/domains/purge-jobs/{job_id}",
|
f"{VM112_API}/api/admin/domains/purge-jobs/{job_id}",
|
||||||
headers=_vm112_headers(),
|
headers=_vm112_headers(),
|
||||||
|
|
@ -183,8 +230,37 @@ def purge_vm112_with_poll(domain: str, poll_interval: float = 1.5, timeout: floa
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
deadline = t0 + timeout
|
deadline = t0 + timeout
|
||||||
seen = 0
|
seen = 0
|
||||||
|
poll_errors = 0
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
job = poll_purge_vm112_job(job_id)
|
try:
|
||||||
|
job = poll_purge_vm112_job(job_id)
|
||||||
|
poll_errors = 0
|
||||||
|
except Exception as exc:
|
||||||
|
poll_errors += 1
|
||||||
|
err = str(exc) or "erro poll VM112"
|
||||||
|
yield (
|
||||||
|
"heartbeat",
|
||||||
|
{
|
||||||
|
"elapsed": int(time.monotonic() - t0),
|
||||||
|
"job_id": job_id,
|
||||||
|
"warn": err,
|
||||||
|
"poll_errors": poll_errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if poll_errors >= 5 and not domain_exists_on_vm112(domain):
|
||||||
|
yield (
|
||||||
|
"final",
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"recovered": True,
|
||||||
|
"steps": [],
|
||||||
|
"result": {"message": "Domínio ausente na VM112 após falhas de poll"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
continue
|
||||||
steps = vm112_job_steps_timeline(job)
|
steps = vm112_job_steps_timeline(job)
|
||||||
if len(steps) > seen:
|
if len(steps) > seen:
|
||||||
for step in steps[seen:]:
|
for step in steps[seen:]:
|
||||||
|
|
@ -217,7 +293,26 @@ def purge_vm112_with_poll(domain: str, poll_interval: float = 1.5, timeout: floa
|
||||||
yield ("heartbeat", {"elapsed": int(time.monotonic() - t0), "job_id": job_id})
|
yield ("heartbeat", {"elapsed": int(time.monotonic() - t0), "job_id": job_id})
|
||||||
time.sleep(poll_interval)
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
yield ("final", {"ok": False, "error": "Timeout purge VM112", "job_id": job_id})
|
if not domain_exists_on_vm112(domain):
|
||||||
|
yield (
|
||||||
|
"final",
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"job_id": job_id,
|
||||||
|
"recovered": True,
|
||||||
|
"steps": [],
|
||||||
|
"result": {"message": "Domínio removido na VM112 (timeout poll — recuperado)"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
yield (
|
||||||
|
"final",
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Timeout purge VM112 — domínio ainda presente; use Recuperar ou repita",
|
||||||
|
"job_id": job_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def purge_vm112(domain: str) -> dict[str, Any]:
|
def purge_vm112(domain: str) -> dict[str, Any]:
|
||||||
|
|
@ -255,46 +350,114 @@ def vm112_purge_timeline(vm112_result: dict[str, Any]) -> list[dict[str, str]]:
|
||||||
|
|
||||||
|
|
||||||
def purge_desk_records(conn: sqlite3.Connection, domain: str) -> dict[str, int]:
|
def purge_desk_records(conn: sqlite3.Connection, domain: str) -> dict[str, int]:
|
||||||
domain = domain.lower().strip()
|
counts, _ = purge_desk_timeline(
|
||||||
like = f"%{domain}%"
|
conn, domain, by_user="system", purge_source="desk.api.purge", record_event=False
|
||||||
counts = {}
|
)
|
||||||
counts["webhook_events"] = conn.execute(
|
|
||||||
"DELETE FROM webhook_events WHERE payload LIKE ?", (like,)
|
|
||||||
).rowcount
|
|
||||||
counts["tickets"] = conn.execute(
|
|
||||||
"DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)
|
|
||||||
).rowcount
|
|
||||||
counts["audit_domains"] = conn.execute(
|
|
||||||
"DELETE FROM audit_domains WHERE domain = ?", (domain,)
|
|
||||||
).rowcount
|
|
||||||
counts["assist_sessions"] = conn.execute(
|
|
||||||
"DELETE FROM assist_sessions WHERE domain = ?", (domain,)
|
|
||||||
).rowcount
|
|
||||||
counts["audit_checks"] = conn.execute(
|
|
||||||
"DELETE FROM audit_checks WHERE domain = ?", (domain,)
|
|
||||||
).rowcount
|
|
||||||
conn.commit()
|
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def purge_desk_timeline(conn: sqlite3.Connection, domain: str) -> tuple[dict[str, int], list[dict[str, str]]]:
|
def purge_domain_console_scenario(conn: sqlite3.Connection, domain: str) -> int:
|
||||||
"""Purge Desk com uma linha de timeline por tabela."""
|
domain = domain.lower().strip()
|
||||||
|
return conn.execute(
|
||||||
|
"DELETE FROM domain_console_scenarios WHERE domain = ?", (domain,)
|
||||||
|
).rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def record_domain_purged_event(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
by_user: str,
|
||||||
|
purge_source: str,
|
||||||
|
desk_removed: dict[str, int] | None = None,
|
||||||
|
vm112_ok: bool | None = None,
|
||||||
|
vm112_error: str | None = None,
|
||||||
|
job_id: str | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Marca expurgo em eventos — histórico preservado (não apaga webhook_events)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"domain": domain.lower().strip(),
|
||||||
|
"event": "domain.purged",
|
||||||
|
"purged_at": now,
|
||||||
|
"by_user": by_user,
|
||||||
|
"purge_source": purge_source,
|
||||||
|
"purge_tool": purge_source_label(purge_source),
|
||||||
|
"message": (
|
||||||
|
f"Domínio expurgado em {now} pelo utilizador {by_user} "
|
||||||
|
f"via {purge_source_label(purge_source)}"
|
||||||
|
),
|
||||||
|
"desk_removed": desk_removed or {},
|
||||||
|
"vm112_ok": vm112_ok,
|
||||||
|
"vm112_error": vm112_error,
|
||||||
|
"job_id": job_id,
|
||||||
|
"accounts_hint": [f"admin@{domain.lower().strip()}", f"mail.{domain.lower().strip()}"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO webhook_events (event_type, source, payload, created_at) VALUES (?,?,?,?)",
|
||||||
|
("domain.purged", "desk.purge", payload, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_desk_timeline(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
by_user: str = "system",
|
||||||
|
purge_source: str = "desk.api.purge",
|
||||||
|
record_event: bool = True,
|
||||||
|
vm112_ok: bool | None = None,
|
||||||
|
vm112_error: str | None = None,
|
||||||
|
job_id: str | None = None,
|
||||||
|
) -> tuple[dict[str, int], list[dict[str, str]]]:
|
||||||
|
"""Purge Desk — mantém webhook_events; regista domain.purged no fim."""
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
like = f"%{domain}%"
|
like = f"%{domain}%"
|
||||||
timeline: list[dict[str, str]] = []
|
timeline: list[dict[str, str]] = []
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
desk_steps = (
|
desk_steps = (
|
||||||
("Desk — webhook_events", "webhook_events", "DELETE FROM webhook_events WHERE payload LIKE ?", (like,)),
|
("Desk — domain_console_scenarios", "domain_console_scenarios", None, None),
|
||||||
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
|
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
|
||||||
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain,)),
|
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain,)),
|
||||||
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain,)),
|
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain,)),
|
||||||
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain,)),
|
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain,)),
|
||||||
)
|
)
|
||||||
for label, key, sql, params in desk_steps:
|
for label, key, sql, params in desk_steps:
|
||||||
n = conn.execute(sql, params).rowcount
|
if key == "domain_console_scenarios":
|
||||||
|
n = purge_domain_console_scenario(conn, domain)
|
||||||
|
else:
|
||||||
|
n = conn.execute(sql, params).rowcount
|
||||||
counts[key] = n
|
counts[key] = n
|
||||||
timeline.append(_timeline_entry(label, "ok", f"{n} registo(s) removido(s)"))
|
timeline.append(_timeline_entry(label, "ok", f"{n} registo(s) removido(s)"))
|
||||||
|
|
||||||
|
event_id = 0
|
||||||
|
if record_event:
|
||||||
|
event_id = record_domain_purged_event(
|
||||||
|
conn,
|
||||||
|
domain,
|
||||||
|
by_user=by_user,
|
||||||
|
purge_source=purge_source,
|
||||||
|
desk_removed=counts,
|
||||||
|
vm112_ok=vm112_ok,
|
||||||
|
vm112_error=vm112_error,
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
counts["webhook_events_purged_marker"] = 1
|
||||||
|
timeline.append(
|
||||||
|
_timeline_entry(
|
||||||
|
"Desk — evento domain.purged",
|
||||||
|
"ok",
|
||||||
|
f"event_id={event_id} · histórico webhook preservado",
|
||||||
|
)
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return counts, timeline
|
return counts, timeline
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ class DomainPurgeRequest(BaseModel):
|
||||||
confirm_domain: str = Field(..., min_length=3)
|
confirm_domain: str = Field(..., min_length=3)
|
||||||
root_password: str = Field(..., min_length=1)
|
root_password: str = Field(..., min_length=1)
|
||||||
purge_auth_code: str = Field("", max_length=32)
|
purge_auth_code: str = Field("", max_length=32)
|
||||||
|
purge_source: str = Field(
|
||||||
|
"desk.api.purge",
|
||||||
|
max_length=64,
|
||||||
|
description="Canal: desk.ui.purge, cursor.agent, domain-console.sandbox, desk.agentic, …",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
def _require_admin(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
|
||||||
|
|
@ -108,7 +113,14 @@ def purge_vm112_domain(
|
||||||
raise HTTPException(502, f"Purge VM112 falhou: {e}") from e
|
raise HTTPException(502, f"Purge VM112 falhou: {e}") from e
|
||||||
conn = auth.db()
|
conn = auth.db()
|
||||||
try:
|
try:
|
||||||
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(
|
||||||
|
conn,
|
||||||
|
domain,
|
||||||
|
by_user=user.username,
|
||||||
|
purge_source=body.purge_source or "desk.api.purge",
|
||||||
|
vm112_ok=vm112_result.get("ok"),
|
||||||
|
vm112_error=vm112_result.get("error"),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
timeline = vm112_domains.build_purge_timeline(vm112_result, desk_counts, desk_timeline)
|
timeline = vm112_domains.build_purge_timeline(vm112_result, desk_counts, desk_timeline)
|
||||||
|
|
@ -119,6 +131,8 @@ def purge_vm112_domain(
|
||||||
"desk": desk_counts,
|
"desk": desk_counts,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
"by": user.username,
|
"by": user.username,
|
||||||
|
"purge_source": body.purge_source,
|
||||||
|
"purge_tool": vm112_domains.purge_source_label(body.purge_source),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -150,7 +164,9 @@ def start_purge_job(
|
||||||
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
"""Inicia purge em background; consultar GET /purge/jobs/{id} (recomendado via Traefik)."""
|
||||||
domain = _validate_purge_request(domain, body, username=user.username)
|
domain = _validate_purge_request(domain, body, username=user.username)
|
||||||
try:
|
try:
|
||||||
job_id = start_job(domain, body.root_password, user.username)
|
job_id = start_job(
|
||||||
|
domain, body.root_password, user.username, purge_source=body.purge_source
|
||||||
|
)
|
||||||
except PurgeJobConflictError as exc:
|
except PurgeJobConflictError as exc:
|
||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
except sqlite3.OperationalError as exc:
|
except sqlite3.OperationalError as exc:
|
||||||
|
|
|
||||||
|
|
@ -292,11 +292,17 @@ def create_job(domain: str, username: str) -> str:
|
||||||
return job_id
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
def start_job(domain: str, root_password: str, username: str) -> str:
|
def start_job(
|
||||||
|
domain: str,
|
||||||
|
root_password: str,
|
||||||
|
username: str,
|
||||||
|
purge_source: str = "desk.api.purge-jobs",
|
||||||
|
) -> str:
|
||||||
job_id = create_job(domain, username)
|
job_id = create_job(domain, username)
|
||||||
|
_set_job(job_id, desk={"purge_source": purge_source})
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_execute_job,
|
target=_execute_job,
|
||||||
args=(job_id, domain, root_password, username),
|
args=(job_id, domain, root_password, username, purge_source),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
@ -320,9 +326,20 @@ def _finish_desk_phase(job_id: str) -> dict[str, Any] | None:
|
||||||
return _load_job(job_id)
|
return _load_job(job_id)
|
||||||
|
|
||||||
domain = job["domain"]
|
domain = job["domain"]
|
||||||
|
purge_source = (job.get("desk") or {}).get("purge_source") or "desk.api.purge-jobs"
|
||||||
|
vm112_ok = (job.get("vm112") or {}).get("ok")
|
||||||
|
vm112_error = job.get("error")
|
||||||
conn = auth.db()
|
conn = auth.db()
|
||||||
try:
|
try:
|
||||||
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(conn, domain)
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(
|
||||||
|
conn,
|
||||||
|
domain,
|
||||||
|
by_user=job.get("by") or "system",
|
||||||
|
purge_source=purge_source,
|
||||||
|
vm112_ok=vm112_ok,
|
||||||
|
vm112_error=vm112_error,
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -339,7 +356,7 @@ def _finish_desk_phase(job_id: str) -> dict[str, Any] | None:
|
||||||
|
|
||||||
|
|
||||||
def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None:
|
def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None:
|
||||||
"""Finaliza job quando VM112 já removeu o domínio (ex.: API reiniciada)."""
|
"""Finaliza job quando VM112 já removeu o domínio (ex.: timeout poll ou API reiniciada)."""
|
||||||
job = _load_job(job_id)
|
job = _load_job(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
if not domain:
|
if not domain:
|
||||||
|
|
@ -375,7 +392,7 @@ def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None
|
||||||
"by": None,
|
"by": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if job["status"] in ("done", "error"):
|
if job["status"] == "done":
|
||||||
return job
|
return job
|
||||||
|
|
||||||
domain = (domain or job["domain"]).lower().strip()
|
domain = (domain or job["domain"]).lower().strip()
|
||||||
|
|
@ -390,10 +407,25 @@ def recover_job(job_id: str, domain: str | None = None) -> dict[str, Any] | None
|
||||||
"Domínio já removido na VM112 (recuperação)",
|
"Domínio já removido na VM112 (recuperação)",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return _finish_desk_phase(job_id)
|
finished = _finish_desk_phase(job_id)
|
||||||
|
if finished:
|
||||||
|
_set_job(
|
||||||
|
job_id,
|
||||||
|
status="done",
|
||||||
|
error=None,
|
||||||
|
vm112={**(finished.get("vm112") or {}), "ok": True, "recovered": True},
|
||||||
|
)
|
||||||
|
return _load_job(job_id)
|
||||||
|
return finished
|
||||||
|
|
||||||
|
|
||||||
def _execute_job(job_id: str, domain: str, root_password: str, username: str) -> None:
|
def _execute_job(
|
||||||
|
job_id: str,
|
||||||
|
domain: str,
|
||||||
|
root_password: str,
|
||||||
|
username: str,
|
||||||
|
purge_source: str = "desk.api.purge-jobs",
|
||||||
|
) -> None:
|
||||||
domain = domain.lower().strip()
|
domain = domain.lower().strip()
|
||||||
try:
|
try:
|
||||||
_set_job(job_id, status="running")
|
_set_job(job_id, status="running")
|
||||||
|
|
@ -438,19 +470,44 @@ def _execute_job(job_id: str, domain: str, root_password: str, username: str) ->
|
||||||
break
|
break
|
||||||
|
|
||||||
if not vm112_result.get("ok", False):
|
if not vm112_result.get("ok", False):
|
||||||
step = vm112_domains._timeline_entry(
|
err = str(vm112_result.get("error") or "falhou")
|
||||||
"Purge VM112",
|
if not vm112_domains.domain_exists_on_vm112(domain):
|
||||||
"fail",
|
_upsert_step(
|
||||||
str(vm112_result.get("error") or "falhou"),
|
job_id,
|
||||||
)
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"ok",
|
||||||
|
"Domínio removido na VM112 (recuperação automática)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_set_job(
|
||||||
|
job_id,
|
||||||
|
vm112={**vm112_result, "ok": True, "recovered": True, "previous_error": err},
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
_finish_desk_phase(job_id)
|
||||||
|
return
|
||||||
|
step = vm112_domains._timeline_entry("Purge VM112", "fail", err)
|
||||||
_upsert_step(job_id, step)
|
_upsert_step(job_id, step)
|
||||||
_set_job(job_id, status="error", error=str(vm112_result.get("error") or "falhou"))
|
_set_job(job_id, status="error", error=err, vm112=vm112_result)
|
||||||
return
|
return
|
||||||
|
|
||||||
_set_job(job_id, vm112=vm112_result)
|
_set_job(job_id, vm112=vm112_result)
|
||||||
_finish_desk_phase(job_id)
|
_finish_desk_phase(job_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
err = str(exc) or "erro inesperado"
|
err = str(exc) or "erro inesperado"
|
||||||
|
if not vm112_domains.domain_exists_on_vm112(domain):
|
||||||
|
_upsert_step(
|
||||||
|
job_id,
|
||||||
|
vm112_domains._timeline_entry(
|
||||||
|
"Purge VM112",
|
||||||
|
"ok",
|
||||||
|
f"Recuperação após excepção: {err}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_set_job(job_id, vm112={"ok": True, "recovered": True, "previous_error": err}, error=None)
|
||||||
|
_finish_desk_phase(job_id)
|
||||||
|
return
|
||||||
_upsert_step(
|
_upsert_step(
|
||||||
job_id,
|
job_id,
|
||||||
vm112_domains._timeline_entry("Purge VM112", "fail", err),
|
vm112_domains._timeline_entry("Purge VM112", "fail", err),
|
||||||
|
|
|
||||||
|
|
@ -65,31 +65,21 @@ def purge_sse_generator(domain: str, root_password: str, username: str) -> Itera
|
||||||
break
|
break
|
||||||
|
|
||||||
conn = auth.db()
|
conn = auth.db()
|
||||||
desk_counts: dict[str, int] = {}
|
|
||||||
try:
|
try:
|
||||||
domain_l = domain.lower().strip()
|
desk_counts, desk_timeline = vm112_domains.purge_desk_timeline(
|
||||||
like = f"%{domain_l}%"
|
conn,
|
||||||
desk_steps = (
|
domain,
|
||||||
("Desk — webhook_events", "webhook_events", "DELETE FROM webhook_events WHERE payload LIKE ?", (like,)),
|
by_user=username,
|
||||||
("Desk — tickets", "tickets", "DELETE FROM tickets WHERE subject LIKE ? OR payload LIKE ?", (like, like)),
|
purge_source="desk.api.purge-stream",
|
||||||
("Desk — audit_domains", "audit_domains", "DELETE FROM audit_domains WHERE domain = ?", (domain_l,)),
|
vm112_ok=vm112_result.get("ok"),
|
||||||
("Desk — assist_sessions", "assist_sessions", "DELETE FROM assist_sessions WHERE domain = ?", (domain_l,)),
|
vm112_error=vm112_result.get("error"),
|
||||||
("Desk — audit_checks", "audit_checks", "DELETE FROM audit_checks WHERE domain = ?", (domain_l,)),
|
|
||||||
)
|
)
|
||||||
for label, key, sql, params in desk_steps:
|
|
||||||
yield _sse({"type": "step", "step": vm112_domains._timeline_entry(label, "running")})
|
|
||||||
n = conn.execute(sql, params).rowcount
|
|
||||||
desk_counts[key] = n
|
|
||||||
yield _sse({
|
|
||||||
"type": "step",
|
|
||||||
"step": vm112_domains._timeline_entry(label, "ok", f"{n} registo(s) removido(s)"),
|
|
||||||
"phase": "desk",
|
|
||||||
})
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
total_desk = sum(desk_counts.values())
|
total_desk = sum(desk_counts.values())
|
||||||
|
for step in desk_timeline:
|
||||||
|
yield _sse({"type": "step", "step": step, "phase": "desk"})
|
||||||
done_step = vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)")
|
done_step = vm112_domains._timeline_entry("Purge concluído", "ok", f"Desk: {total_desk} registo(s)")
|
||||||
yield _sse({
|
yield _sse({
|
||||||
"type": "done",
|
"type": "done",
|
||||||
|
|
|
||||||
|
|
@ -276,11 +276,35 @@ function setView(name) {
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navegação cross-módulo (Spec 018 — cards Escopo OPS). */
|
||||||
|
window.DeskNavigate = {
|
||||||
|
go(view, opts = {}) {
|
||||||
|
if (opts.eventsTab) state.eventsTab = opts.eventsTab;
|
||||||
|
if (opts.ticketFilter) state.ticketFilter = opts.ticketFilter;
|
||||||
|
setView(view);
|
||||||
|
if (view === 'events' && opts.eventsTab) {
|
||||||
|
document.querySelectorAll('[data-events-tab]').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.dataset.eventsTab === opts.eventsTab);
|
||||||
|
});
|
||||||
|
renderEvents();
|
||||||
|
}
|
||||||
|
if (view === 'tickets' && opts.ticketFilter) {
|
||||||
|
document.querySelectorAll('.filter-btn[data-filter]').forEach((b) => {
|
||||||
|
b.classList.toggle('active', b.dataset.filter === opts.ticketFilter);
|
||||||
|
});
|
||||||
|
renderTickets();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let socRenderInFlight = false;
|
let socRenderInFlight = false;
|
||||||
function reschedulePoll() {
|
function reschedulePoll() {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
const ms = state.view === 'infra2' ? 15000 : 30000;
|
let ms = 30000;
|
||||||
|
if (state.view === 'infra2') ms = 15000;
|
||||||
|
// VM112 /admin/domains ~10–15s — poll menos agressivo (Spec 018 Serviços IaaS)
|
||||||
|
if (state.view === 'overview-home') ms = 90000;
|
||||||
pollTimer = setInterval(() => refresh({ poll: true }), ms);
|
pollTimer = setInterval(() => refresh({ poll: true }), ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2174,12 +2198,14 @@ async function renderEvents(options = {}) {
|
||||||
const rows = (data.events || []).map((e) => {
|
const rows = (data.events || []).map((e) => {
|
||||||
const p = e.payload || {};
|
const p = e.payload || {};
|
||||||
const dataObj = p.data || {};
|
const dataObj = p.data || {};
|
||||||
return `<tr>
|
const domain = p.domain || e.domain || '—';
|
||||||
|
const ref = (p.session_id || p.job_id || '').slice(0, 16);
|
||||||
|
return `<tr class="event-auditor-row" data-event-id="${e.id}" tabindex="0" role="button" title="Abrir auditor">
|
||||||
<td>${e.id}</td>
|
<td>${e.id}</td>
|
||||||
<td>${sourceBadge(e.source)}</td>
|
<td>${sourceBadge(e.source)}</td>
|
||||||
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
|
<td><span class="badge open">${esc(e.event_type)}</span> ${severityBadge(dataObj.level || e.severity)}</td>
|
||||||
<td>${esc(p.domain || '—')}</td>
|
<td>${esc(domain)}</td>
|
||||||
<td><code>${esc((p.session_id || '').slice(0, 16))}</code></td>
|
<td><code>${esc(ref || '—')}</code></td>
|
||||||
<td>${fmtDate(e.created_at)}</td>
|
<td>${fmtDate(e.created_at)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -2190,6 +2216,13 @@ async function renderEvents(options = {}) {
|
||||||
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
|
<tbody>${rows || '<tr><td colspan="6">Sem eventos</td></tr>'}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
el.querySelectorAll('.event-auditor-row').forEach((row) => {
|
||||||
|
const open = () => openEventAuditorModal(Number(row.dataset.eventId));
|
||||||
|
row.addEventListener('click', open);
|
||||||
|
row.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); open(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -2501,6 +2534,126 @@ function renderPurgeTimelineHtml(steps) {
|
||||||
}).join('')}</ul>`;
|
}).join('')}</ul>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PURGE_DESK_LABELS = {
|
||||||
|
webhook_events_purged_marker: 'Marcador domain.purged (histórico preservado)',
|
||||||
|
domain_console_scenarios: 'Cenários Domain Console',
|
||||||
|
tickets: 'Tickets',
|
||||||
|
audit_domains: 'Domínios audit',
|
||||||
|
assist_sessions: 'Sessões assist',
|
||||||
|
audit_checks: 'Checks audit',
|
||||||
|
};
|
||||||
|
|
||||||
|
function purgeDeskRowsHtml(desk) {
|
||||||
|
const keys = Object.keys(PURGE_DESK_LABELS);
|
||||||
|
const extra = Object.keys(desk || {}).filter((k) => !keys.includes(k) && k !== 'purge_source');
|
||||||
|
const allKeys = [...keys, ...extra];
|
||||||
|
const rows = allKeys.map((key) => {
|
||||||
|
const n = Number(desk?.[key] || 0);
|
||||||
|
if (!n && !keys.includes(key)) return '';
|
||||||
|
const label = PURGE_DESK_LABELS[key] || key;
|
||||||
|
return `<tr><td>${esc(label)}</td><td>${n}</td></tr>`;
|
||||||
|
}).filter(Boolean).join('');
|
||||||
|
const total = Object.entries(desk || {})
|
||||||
|
.filter(([k]) => k !== 'purge_source')
|
||||||
|
.reduce((a, [, v]) => a + Number(v || 0), 0);
|
||||||
|
return { rows, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEventAuditorModal() {
|
||||||
|
const modal = document.getElementById('event-auditor-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDomainPurgedAuditor(ev) {
|
||||||
|
const p = ev.payload || {};
|
||||||
|
const desk = p.desk_removed || {};
|
||||||
|
const deskRows = Object.entries(desk).map(([k, v]) =>
|
||||||
|
`<tr><td>${esc(PURGE_DESK_LABELS[k] || k)}</td><td>${Number(v)}</td></tr>`,
|
||||||
|
).join('');
|
||||||
|
const jobLink = p.job_id
|
||||||
|
? `<button type="button" class="btn btn-ghost btn-sm" data-open-purge-job="${esc(p.job_id)}">Ver job ${esc(p.job_id)}</button>`
|
||||||
|
: '';
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<h4>Expurgo de domínio</h4>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Domínio</dt><dd><strong>${esc(p.domain || ev.domain || '—')}</strong></dd>
|
||||||
|
<dt>Utilizador</dt><dd>${esc(p.by_user || '—')}</dd>
|
||||||
|
<dt>Ferramenta</dt><dd>${esc(p.purge_tool || p.purge_source || '—')}</dd>
|
||||||
|
<dt>Expurgado em</dt><dd>${fmtDate(p.purged_at || ev.created_at)}</dd>
|
||||||
|
<dt>VM112</dt><dd>${p.vm112_ok === false ? `<span class="badge error">falhou</span> ${esc(p.vm112_error || '')}` : '<span class="badge ok">OK</span>'}</dd>
|
||||||
|
${jobLink ? `<dt>Job purge</dt><dd>${jobLink}</dd>` : ''}
|
||||||
|
</dl>
|
||||||
|
${p.message ? `<p class="ticket-meta">${esc(p.message)}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
${deskRows ? `<div class="card" style="margin-top:1rem"><h4>Removido no Desk</h4><table class="purge-history-kv"><tbody>${deskRows}</tbody></table></div>` : ''}
|
||||||
|
<h4 style="margin-top:1rem">Payload completo</h4>
|
||||||
|
<pre class="raw">${esc(JSON.stringify(p, null, 2))}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEventAuditorModal(eventId) {
|
||||||
|
const modal = document.getElementById('event-auditor-modal');
|
||||||
|
const title = document.getElementById('event-auditor-modal-title');
|
||||||
|
const sub = document.getElementById('event-auditor-modal-sub');
|
||||||
|
const body = document.getElementById('event-auditor-modal-body');
|
||||||
|
if (!modal || !body) return;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
title.textContent = 'Auditor de Eventos';
|
||||||
|
sub.textContent = `webhook · ${eventId}`;
|
||||||
|
body.innerHTML = '<p class="loading">Carregando…</p>';
|
||||||
|
api(`/v1/webhooks/events/${encodeURIComponent(eventId)}`)
|
||||||
|
.then(async (data) => {
|
||||||
|
const ev = data.event || {};
|
||||||
|
sub.textContent = `${esc(ev.source || 'webhook')} · ${ev.id}`;
|
||||||
|
if (ev.event_type === 'domain.purged') {
|
||||||
|
body.innerHTML = renderDomainPurgedAuditor(ev);
|
||||||
|
body.querySelector('[data-open-purge-job]')?.addEventListener('click', (btn) => {
|
||||||
|
closeEventAuditorModal();
|
||||||
|
openPurgeHistoryModal(btn.currentTarget.dataset.openPurgeJob);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = ev.payload || {};
|
||||||
|
const sid = (p.session_id || ev.session_id || '').trim();
|
||||||
|
let timelineBlock = '';
|
||||||
|
if (sid && ev.source === 'vm112-onboard') {
|
||||||
|
try {
|
||||||
|
const tl = await api(`/v1/onboard/sessions/${encodeURIComponent(sid)}/timeline`);
|
||||||
|
const events = tl.events || tl.timeline || [];
|
||||||
|
if (events.length) {
|
||||||
|
timelineBlock = `
|
||||||
|
<h4 style="margin-top:1rem">Timeline onboard (${esc(sid.slice(0, 16))}…)</h4>
|
||||||
|
${timelineHtml(events, null, { compact: true })}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* timeline opcional */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="card">
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Tipo</dt><dd><span class="badge open">${esc(ev.event_type)}</span></dd>
|
||||||
|
<dt>Origem</dt><dd>${sourceBadge(ev.source)}</dd>
|
||||||
|
<dt>Domínio</dt><dd>${esc(p.domain || ev.domain || '—')}</dd>
|
||||||
|
<dt>Sessão</dt><dd><code>${esc(sid || '—')}</code></dd>
|
||||||
|
<dt>Data</dt><dd>${fmtDate(ev.created_at)}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
${timelineBlock}
|
||||||
|
<h4 style="margin-top:1rem">Payload</h4>
|
||||||
|
<pre class="raw">${esc(JSON.stringify(p, null, 2))}</pre>`;
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-close-event-auditor-modal]').forEach((el) => {
|
||||||
|
el.onclick = closeEventAuditorModal;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function closePurgeHistoryModal() {
|
function closePurgeHistoryModal() {
|
||||||
const modal = document.getElementById('purge-history-modal');
|
const modal = document.getElementById('purge-history-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
@ -2525,23 +2678,19 @@ function openPurgeHistoryModal(jobId) {
|
||||||
sub.innerHTML = `${purgeStatusBadge(job.status)} · ${esc(job.by || '—')} · ${fmtDate(job.created_at)} · job <code>${esc(job.id)}</code>`;
|
sub.innerHTML = `${purgeStatusBadge(job.status)} · ${esc(job.by || '—')} · ${fmtDate(job.created_at)} · job <code>${esc(job.id)}</code>`;
|
||||||
const desk = job.desk || {};
|
const desk = job.desk || {};
|
||||||
const vm112 = job.vm112 || {};
|
const vm112 = job.vm112 || {};
|
||||||
const deskRows = Object.entries({
|
const { rows: deskRows, total: deskTotal } = purgeDeskRowsHtml(desk);
|
||||||
webhook_events: 'Eventos webhook',
|
|
||||||
tickets: 'Tickets',
|
|
||||||
audit_domains: 'Domínios audit',
|
|
||||||
assist_sessions: 'Sessões assist',
|
|
||||||
audit_checks: 'Checks audit',
|
|
||||||
}).map(([key, label]) => `
|
|
||||||
<tr><td>${esc(label)}</td><td>${Number(desk[key] || 0)}</td></tr>`).join('');
|
|
||||||
const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
|
const vm112Steps = Array.isArray(vm112.steps) ? vm112.steps : [];
|
||||||
const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
|
const timeline = (job.timeline || []).length ? job.timeline : vm112Steps;
|
||||||
|
const recoverBtn = job.status === 'error'
|
||||||
|
? `<button type="button" class="btn btn-primary btn-sm" id="purge-history-recover-btn">Recuperar purge (VM112 já limpa?)</button>`
|
||||||
|
: '';
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="purge-history-grid">
|
<div class="purge-history-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h4>Removido no Desk (VM122)</h4>
|
<h4>Removido no Desk (VM122)</h4>
|
||||||
<table class="purge-history-kv">
|
<table class="purge-history-kv">
|
||||||
<tbody>${deskRows}</tbody>
|
<tbody>${deskRows || '<tr><td colspan="2">Nenhum registo Desk removido</td></tr>'}</tbody>
|
||||||
<tfoot><tr><td><strong>Total</strong></td><td><strong>${Object.values(desk).reduce((a, b) => a + Number(b || 0), 0)}</strong></td></tr></tfoot>
|
<tfoot><tr><td><strong>Total</strong></td><td><strong>${deskTotal}</strong></td></tr></tfoot>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -2549,12 +2698,28 @@ function openPurgeHistoryModal(jobId) {
|
||||||
<p class="purge-history-vm112-sum">${vm112RemovedSummary(vm112)}</p>
|
<p class="purge-history-vm112-sum">${vm112RemovedSummary(vm112)}</p>
|
||||||
${job.elapsed_vm112 ? `<p class="ticket-meta">Duração VM112: ${job.elapsed_vm112}s</p>` : ''}
|
${job.elapsed_vm112 ? `<p class="ticket-meta">Duração VM112: ${job.elapsed_vm112}s</p>` : ''}
|
||||||
${job.error ? `<p class="purge-history-error">${esc(job.error)}</p>` : ''}
|
${job.error ? `<p class="purge-history-error">${esc(job.error)}</p>` : ''}
|
||||||
|
${recoverBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="margin-top:1rem">
|
<div class="card" style="margin-top:1rem">
|
||||||
<h4>Timeline completa</h4>
|
<h4>Timeline completa</h4>
|
||||||
${timeline.length ? renderPurgeTimelineHtml(timeline) : '<p class="loading">Sem passos registados</p>'}
|
${timeline.length ? renderPurgeTimelineHtml(timeline) : '<p class="loading">Sem passos registados</p>'}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
body.querySelector('#purge-history-recover-btn')?.addEventListener('click', async () => {
|
||||||
|
const btn = body.querySelector('#purge-history-recover-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'A recuperar…'; }
|
||||||
|
try {
|
||||||
|
const recovered = await api(
|
||||||
|
`/v1/vm112/purge/jobs/${encodeURIComponent(jobId)}/recover`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
openPurgeHistoryModal(recovered.id || jobId);
|
||||||
|
await renderPurgeHistory();
|
||||||
|
} catch (e) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Recuperar purge (VM112 já limpa?)'; }
|
||||||
|
alert(e.message || 'Recuperação falhou');
|
||||||
|
}
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
|
|
@ -4330,7 +4495,7 @@ async function renderPurgeAuthInfraPanel() {
|
||||||
|
|
||||||
async function refresh(options = {}) {
|
async function refresh(options = {}) {
|
||||||
const { poll = false } = options;
|
const { poll = false } = options;
|
||||||
await loadHealth();
|
void loadHealth();
|
||||||
if (poll && state.view === 'account') {
|
if (poll && state.view === 'account') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -4399,6 +4564,10 @@ document.querySelectorAll('[data-close-purge-history-modal]').forEach((el) => {
|
||||||
el.addEventListener('click', closePurgeHistoryModal);
|
el.addEventListener('click', closePurgeHistoryModal);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-close-event-auditor-modal]').forEach((el) => {
|
||||||
|
el.addEventListener('click', closeEventAuditorModal);
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
||||||
if (state.view === 'account') {
|
if (state.view === 'account') {
|
||||||
state.accountLoaded = false;
|
state.accountLoaded = false;
|
||||||
|
|
@ -4406,6 +4575,19 @@ document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (ev) => {
|
||||||
|
const btn = ev.target.closest('.js-console-dns');
|
||||||
|
if (!btn || typeof openConsoleDnsViewer !== 'function') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
openConsoleDnsViewer(btn.dataset.domain || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nav-console')?.addEventListener('click', (ev) => {
|
||||||
|
if (typeof openConsolePath !== 'function') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
openConsolePath('/admin/dominio');
|
||||||
|
});
|
||||||
|
|
||||||
(async function boot() {
|
(async function boot() {
|
||||||
const dash = document.getElementById('dashboard-content');
|
const dash = document.getElementById('dashboard-content');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
const AUTH_TOKEN_KEY = 'ligbox_ops_token';
|
const AUTH_TOKEN_KEY = 'ligbox_ops_token';
|
||||||
const AUTH_USER_KEY = 'ligbox_ops_user';
|
const AUTH_USER_KEY = 'ligbox_ops_user';
|
||||||
|
const SSO_COOKIE = 'ligbox_sso_token';
|
||||||
|
const SSO_MAX_AGE = 8 * 3600;
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
const re = new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}=([^;]*)`);
|
||||||
|
const m = document.cookie.match(re);
|
||||||
|
return m ? decodeURIComponent(m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSsoCookie(token, maxAgeSec = SSO_MAX_AGE) {
|
||||||
|
if (!token) return;
|
||||||
|
document.cookie = `${SSO_COOKIE}=${encodeURIComponent(token)}; domain=.ligbox.com.br; path=/; max-age=${maxAgeSec}; secure; samesite=lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSsoCookie() {
|
||||||
|
document.cookie = `${SSO_COOKIE}=; domain=.ligbox.com.br; path=/; max-age=0; secure; samesite=lax`;
|
||||||
|
}
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return sessionStorage.getItem(AUTH_TOKEN_KEY);
|
return sessionStorage.getItem(AUTH_TOKEN_KEY);
|
||||||
|
|
@ -13,14 +30,16 @@ function getUser() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSession(token, user) {
|
function setSession(token, user, opts = {}) {
|
||||||
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
|
sessionStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||||
sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
|
sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
|
||||||
|
writeSsoCookie(token, opts.maxAgeSec ?? SSO_MAX_AGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
sessionStorage.removeItem(AUTH_TOKEN_KEY);
|
||||||
sessionStorage.removeItem(AUTH_USER_KEY);
|
sessionStorage.removeItem(AUTH_USER_KEY);
|
||||||
|
clearSsoCookie();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn() {
|
function isLoggedIn() {
|
||||||
|
|
@ -68,9 +87,9 @@ async function ensureValidSession() {
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('ensureValidSession:', err?.name || err);
|
// API reiniciando / rede — não expulsar sessão (evita flash login em Serviços IaaS)
|
||||||
clearSession();
|
console.warn('ensureValidSession (rede, sessão mantida):', err?.name || err);
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,11 +144,15 @@ async function login(username, password) {
|
||||||
if (data.mfa_required) {
|
if (data.mfa_required) {
|
||||||
return { mfaRequired: true, mfaToken: data.mfa_token, username: data.username };
|
return { mfaRequired: true, mfaToken: data.mfa_token, username: data.username };
|
||||||
}
|
}
|
||||||
setSession(data.access_token, {
|
setSession(
|
||||||
username: data.username,
|
data.access_token,
|
||||||
role: data.role,
|
{
|
||||||
display_name: data.display_name,
|
username: data.username,
|
||||||
});
|
role: data.role,
|
||||||
|
display_name: data.display_name,
|
||||||
|
},
|
||||||
|
{ maxAgeSec: data.expires_in || SSO_MAX_AGE },
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,11 +169,15 @@ async function loginMfa(mfaToken, totpCode, backupCode) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.detail || `Código 2FA inválido (${res.status})`);
|
throw new Error(data.detail || `Código 2FA inválido (${res.status})`);
|
||||||
}
|
}
|
||||||
setSession(data.access_token, {
|
setSession(
|
||||||
username: data.username,
|
data.access_token,
|
||||||
role: data.role,
|
{
|
||||||
display_name: data.display_name,
|
username: data.username,
|
||||||
});
|
role: data.role,
|
||||||
|
display_name: data.display_name,
|
||||||
|
},
|
||||||
|
{ maxAgeSec: data.expires_in || SSO_MAX_AGE },
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,11 +212,47 @@ async function mfaRecoveryComplete(recoveryToken, totpCode) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.detail || `Recuperação falhou (${res.status})`);
|
if (!res.ok) throw new Error(data.detail || `Recuperação falhou (${res.status})`);
|
||||||
if (data.access_token) {
|
if (data.access_token) {
|
||||||
setSession(data.access_token, {
|
setSession(
|
||||||
username: data.username,
|
data.access_token,
|
||||||
role: data.role,
|
{
|
||||||
display_name: data.display_name,
|
username: data.username,
|
||||||
});
|
role: data.role,
|
||||||
|
display_name: data.display_name,
|
||||||
|
},
|
||||||
|
{ maxAgeSec: data.expires_in || SSO_MAX_AGE },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canReadDnsViewer() {
|
||||||
|
return hasRole('super_admin', 'ops_lead', 'technician', 'noc', 'seo', 'devops', 'developer');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desk → Console (handoff + cookie SSO .ligbox.com.br). */
|
||||||
|
async function openConsolePath(path, { domain } = {}) {
|
||||||
|
const url = new URL(path, 'https://console.ligbox.com.br');
|
||||||
|
const dom = (domain || '').trim().toLowerCase();
|
||||||
|
if (dom) url.searchParams.set('domain', dom);
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
window.open(url.toString(), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout('/api/v1/auth/console-handoff', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.handoff_token) {
|
||||||
|
url.searchParams.set('desk_handoff', data.handoff_token);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('openConsolePath handoff:', err?.name || err);
|
||||||
|
}
|
||||||
|
window.open(url.toString(), '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConsoleDnsViewer(domain) {
|
||||||
|
return openConsolePath('/admin/dominio', { domain });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,88 @@ const DeskServices = (() => {
|
||||||
let _selectedClientId = null;
|
let _selectedClientId = null;
|
||||||
let _selectedServiceId = 'email_tenant';
|
let _selectedServiceId = 'email_tenant';
|
||||||
let _billingByDomain = {};
|
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 = [
|
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: 'carbonio',
|
||||||
{ id: 'portal', label: 'Portal users', desc: 'Contas Self-Service ligadas ao domínio' },
|
label: 'Carbonio',
|
||||||
{ id: 'cloudflare', label: 'Cloudflare', desc: 'Zona DNS na conta Ibytera' },
|
desc: 'Domínio e contas de e-mail (zmprov)',
|
||||||
{ id: 'traefik', label: 'Traefik / SNI', desc: 'Routers mail.* no CT114' },
|
layer: 'app',
|
||||||
{ id: 'desk', label: 'Desk OPS', desc: 'Webhooks, tickets e audit_domains' },
|
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 = [
|
const SERVICE_CATALOG = [
|
||||||
|
|
@ -30,6 +104,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'VM112',
|
wizardHost: 'VM112',
|
||||||
deliveryModel: 'saas',
|
deliveryModel: 'saas',
|
||||||
category: 'apps',
|
category: 'apps',
|
||||||
|
layerScope: 'app',
|
||||||
phase: 'active',
|
phase: 'active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -41,6 +116,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'VM112',
|
wizardHost: 'VM112',
|
||||||
deliveryModel: 'saas',
|
deliveryModel: 'saas',
|
||||||
category: 'apps',
|
category: 'apps',
|
||||||
|
layerScope: 'tenant',
|
||||||
phase: 'active',
|
phase: 'active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +128,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'futuro',
|
wizardHost: 'futuro',
|
||||||
deliveryModel: 'saas',
|
deliveryModel: 'saas',
|
||||||
category: 'apps',
|
category: 'apps',
|
||||||
|
layerScope: 'infra',
|
||||||
phase: 'planned',
|
phase: 'planned',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +140,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'futuro',
|
wizardHost: 'futuro',
|
||||||
deliveryModel: 'iaas',
|
deliveryModel: 'iaas',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
|
layerScope: 'infra',
|
||||||
phase: 'planned',
|
phase: 'planned',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -74,6 +152,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'futuro',
|
wizardHost: 'futuro',
|
||||||
deliveryModel: 'iaas',
|
deliveryModel: 'iaas',
|
||||||
category: 'infra',
|
category: 'infra',
|
||||||
|
layerScope: 'infra',
|
||||||
phase: 'planned',
|
phase: 'planned',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -85,6 +164,7 @@ const DeskServices = (() => {
|
||||||
wizardHost: 'futuro',
|
wizardHost: 'futuro',
|
||||||
deliveryModel: 'saas',
|
deliveryModel: 'saas',
|
||||||
category: 'security',
|
category: 'security',
|
||||||
|
layerScope: 'app',
|
||||||
phase: 'planned',
|
phase: 'planned',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -167,24 +247,69 @@ const DeskServices = (() => {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadBillingMap() {
|
||||||
if (!window.DeskModules?.isEnabled('billing-recurrence')) return;
|
if (!window.DeskModules?.isEnabled('billing-recurrence')) return;
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch('/v1/billing/accounts?limit=500');
|
const data = await apiFetch('/v1/billing/accounts?limit=500', {}, 15000);
|
||||||
_billingByDomain = {};
|
_billingByDomain = {};
|
||||||
for (const a of data.accounts || []) _billingByDomain[a.domain] = a;
|
for (const a of data.accounts || []) _billingByDomain[a.domain] = a;
|
||||||
} catch { _billingByDomain = {}; }
|
} catch { _billingByDomain = {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDomains() {
|
async function loadDomains(options = {}) {
|
||||||
const data = await apiFetch('/v1/vm112/domains', {}, 120000);
|
const timeoutMs = options.timeoutMs ?? DOMAINS_FETCH_TIMEOUT_MS;
|
||||||
_domains = data.domains || [];
|
if (_loadInFlight) return _loadInFlight;
|
||||||
await loadBillingMap();
|
_loadInFlight = (async () => {
|
||||||
_clients = buildClients(_domains);
|
try {
|
||||||
if (_selectedClientId && !_clients.some((c) => c.id === _selectedClientId)) {
|
const data = await apiFetch('/v1/vm112/domains', {}, timeoutMs);
|
||||||
_selectedClientId = null;
|
applyDomains(data.domains || []);
|
||||||
}
|
writeDomainsCache(_domains);
|
||||||
return _domains;
|
await loadBillingMap();
|
||||||
|
return _domains;
|
||||||
|
} finally {
|
||||||
|
_loadInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return _loadInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredClients() {
|
function filteredClients() {
|
||||||
|
|
@ -201,6 +326,10 @@ const DeskServices = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPurgeBlocked(domain) {
|
||||||
|
return PURGE_BLOCKLIST.has(String(domain || '').toLowerCase().trim());
|
||||||
|
}
|
||||||
|
|
||||||
function selectedClient() {
|
function selectedClient() {
|
||||||
return _clients.find((c) => c.id === _selectedClientId) || null;
|
return _clients.find((c) => c.id === _selectedClientId) || null;
|
||||||
}
|
}
|
||||||
|
|
@ -247,15 +376,19 @@ const DeskServices = (() => {
|
||||||
const sel = _selectedClientId === c.id ? ' servicos-client-row--selected' : '';
|
const sel = _selectedClientId === c.id ? ' servicos-client-row--selected' : '';
|
||||||
const emailSt = serviceStatus('email_tenant', c);
|
const emailSt = serviceStatus('email_tenant', c);
|
||||||
const offers = activeOfferCount(c);
|
const offers = activeOfferCount(c);
|
||||||
|
const protectedDom = isPurgeBlocked(c.domain);
|
||||||
return `
|
return `
|
||||||
<button type="button" class="servicos-client-row${sel}" data-client-id="${esc(c.id)}">
|
<button type="button" class="servicos-client-row${sel}" data-client-id="${esc(c.id)}">
|
||||||
<span class="servicos-client-dot ${c.health}"></span>
|
<span class="servicos-client-dot ${c.health}"></span>
|
||||||
<div class="servicos-client-main">
|
<div class="servicos-client-main">
|
||||||
<strong>${esc(c.displayName)}</strong>
|
<strong title="${esc(c.displayName)}">${esc(c.displayName)}</strong>
|
||||||
<span>${esc(c.subtitle)} · ${offers} tenant(s) de oferta</span>
|
<span>${esc(c.subtitle)} · ${offers} tenant(s) de oferta</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="servicos-client-badge servicos-client-badge--${emailSt}">${statusLabel(emailSt)}</span>
|
<div class="servicos-client-badges">
|
||||||
${_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>` : '')}
|
${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>`;
|
</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,11 +408,13 @@ const DeskServices = (() => {
|
||||||
const wizNote = service.wizard
|
const wizNote = service.wizard
|
||||||
? `wizard: ${service.wizard} (${service.wizardHost})`
|
? `wizard: ${service.wizard} (${service.wizardHost})`
|
||||||
: (service.phase === 'planned' ? 'wizard próprio — planeamento MOSP' : '');
|
: (service.phase === 'planned' ? 'wizard próprio — planeamento MOSP' : '');
|
||||||
|
const layerLabel = LAYER_LABELS[service.layerScope] || LAYER_LABELS.tenant;
|
||||||
return `
|
return `
|
||||||
<article class="servicos-tile servicos-tile--${status}${sel}${clickable ? ' servicos-tile--clickable' : ''}"
|
<article class="servicos-tile servicos-tile--${status}${sel}${clickable ? ' servicos-tile--clickable' : ''}"
|
||||||
data-service-id="${esc(service.id)}"
|
data-service-id="${esc(service.id)}"
|
||||||
data-clickable="${clickable ? '1' : '0'}">
|
data-clickable="${clickable ? '1' : '0'}">
|
||||||
<span class="servicos-tile-tier servicos-tile-tier--${esc(service.deliveryModel)}">${esc(dm)}</span>
|
<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>
|
<div class="servicos-tile-icon" aria-hidden="true">${service.icon}</div>
|
||||||
<h4 class="servicos-tile-title">${esc(service.label)}</h4>
|
<h4 class="servicos-tile-title">${esc(service.label)}</h4>
|
||||||
<p class="servicos-tile-desc">${esc(service.desc)}</p>
|
<p class="servicos-tile-desc">${esc(service.desc)}</p>
|
||||||
|
|
@ -320,6 +455,31 @@ const DeskServices = (() => {
|
||||||
<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>`;
|
<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() {
|
function scopesHtml() {
|
||||||
const client = selectedClient();
|
const client = selectedClient();
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -328,11 +488,7 @@ const DeskServices = (() => {
|
||||||
if (_selectedServiceId !== 'email_tenant') {
|
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 `<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) => `
|
return `${blocklistBannerHtml(client.domain)}${OPS_SCOPES.map(scopeCardHtml).join('')}`;
|
||||||
<div class="servicos-scope-item">
|
|
||||||
<strong>${esc(s.label)}</strong>
|
|
||||||
<span>${esc(s.desc)}</span>
|
|
||||||
</div>`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHtml() {
|
function pageHtml() {
|
||||||
|
|
@ -381,6 +537,7 @@ const DeskServices = (() => {
|
||||||
if (stats) stats.innerHTML = statsHtml();
|
if (stats) stats.innerHTML = statsHtml();
|
||||||
bindClientClicks(root);
|
bindClientClicks(root);
|
||||||
bindServiceClicks(root);
|
bindServiceClicks(root);
|
||||||
|
bindScopeClicks(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindPage(root) {
|
function bindPage(root) {
|
||||||
|
|
@ -395,6 +552,7 @@ const DeskServices = (() => {
|
||||||
});
|
});
|
||||||
bindClientClicks(root);
|
bindClientClicks(root);
|
||||||
bindServiceClicks(root);
|
bindServiceClicks(root);
|
||||||
|
bindScopeClicks(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindClientClicks(root) {
|
function bindClientClicks(root) {
|
||||||
|
|
@ -407,6 +565,42 @@ const DeskServices = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function bindServiceClicks(root) {
|
||||||
root.querySelectorAll('[data-service-id]').forEach((tile) => {
|
root.querySelectorAll('[data-service-id]').forEach((tile) => {
|
||||||
tile.addEventListener('click', () => {
|
tile.addEventListener('click', () => {
|
||||||
|
|
@ -783,6 +977,7 @@ const DeskServices = (() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
stopVm112LoadStepAnimation();
|
||||||
closePurgeDrawer();
|
closePurgeDrawer();
|
||||||
const modal = document.getElementById('vm112-domain-modal');
|
const modal = document.getElementById('vm112-domain-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
|
@ -790,46 +985,29 @@ const DeskServices = (() => {
|
||||||
modal.setAttribute('aria-hidden', 'true');
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(domain) {
|
function renderModalBody(d, domain) {
|
||||||
const modal = document.getElementById('vm112-domain-modal');
|
const accN = (d.accounts || []).length;
|
||||||
const body = document.getElementById('vm112-domain-modal-body');
|
const steps = (d.infra_status?.steps || [])
|
||||||
const title = document.getElementById('vm112-domain-modal-title');
|
.map((s) => `<li class="${s.ok ? 'ok' : 'fail'}"><strong>${esc(s.label)}</strong> — ${esc(s.message)}</li>`)
|
||||||
const sub = document.getElementById('vm112-domain-modal-sub');
|
.join('');
|
||||||
if (!modal || !body) return;
|
const accounts = (d.accounts || [])
|
||||||
modal.classList.remove('hidden');
|
.map((a) => `<li>${esc(typeof a === 'string' ? a : a.email || a)}</li>`).join('')
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
|| '<li class="muted">Nenhuma</li>';
|
||||||
title.textContent = domain;
|
const cf = d.cloudflare_zone;
|
||||||
sub.textContent = 'E-mail Tenant · a carregar…';
|
const cfTxt = cf?.name ? `Zona ${cf.name} (${cf.status || '—'})` : (cf?.error ? `Erro CF: ${cf.error}` : 'Sem zona Cloudflare Ibytera');
|
||||||
body.innerHTML = '<p class="loading">A carregar detalhe VM112…</p>';
|
const extraAuth = Boolean(d.purge_extra_auth_required);
|
||||||
loadModal(domain);
|
const purgeBlocked = Boolean(d.purge_blocked);
|
||||||
modal.querySelectorAll('[data-close-vm112-modal]').forEach((el) => {
|
const authCodeField = extraAuth
|
||||||
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';
|
|
||||||
const extraAuth = Boolean(d.purge_extra_auth_required);
|
|
||||||
const authCodeField = extraAuth
|
|
||||||
? `
|
|
||||||
<p class="vm112-purge-warn">Domínio protegido — exige <strong>código de autorização</strong> gerado em Infra (root).</p>
|
<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>
|
<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"/>
|
<input type="text" id="vm112-purge-auth-code" class="vm112-purge-input" placeholder="XXXX-XXXX" autocomplete="off" spellcheck="false"/>
|
||||||
`
|
`
|
||||||
: '';
|
: '';
|
||||||
body.innerHTML = `
|
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">
|
<div class="modal-section">
|
||||||
<h4>Serviço: E-mail Tenant</h4>
|
<h4>Serviço: E-mail Tenant</h4>
|
||||||
<p><strong>Cliente / domínio:</strong> ${esc(domain)}</p>
|
<p><strong>Cliente / domínio:</strong> ${esc(domain)}</p>
|
||||||
|
|
@ -843,7 +1021,7 @@ const DeskServices = (() => {
|
||||||
<ul class="vm112-infra-steps">${steps || '<li>—</li>'}</ul>
|
<ul class="vm112-infra-steps">${steps || '<li>—</li>'}</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-section">
|
<div class="modal-section">
|
||||||
<h4>Contas Carbonio</h4>
|
<h4>Contas Carbonio (${accN})</h4>
|
||||||
<ul>${accounts}</ul>
|
<ul>${accounts}</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-section vm112-purge-zone">
|
<div class="modal-section vm112-purge-zone">
|
||||||
|
|
@ -854,12 +1032,129 @@ const DeskServices = (() => {
|
||||||
<label>Senha Root</label>
|
<label>Senha Root</label>
|
||||||
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
|
<input type="password" id="vm112-purge-root-pwd" class="vm112-purge-input" autocomplete="current-password"/>
|
||||||
${authCodeField}
|
${authCodeField}
|
||||||
<button type="button" class="btn btn-danger" id="vm112-purge-btn">Apagar domínio e todos os dados</button>
|
${purgeBtn}
|
||||||
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
<p id="vm112-purge-msg" class="ticket-meta"></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
body.querySelector('#vm112-purge-btn')?.addEventListener('click', () => runPurge(domain));
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
body.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -880,6 +1175,15 @@ const DeskServices = (() => {
|
||||||
el.textContent = message;
|
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 = {}) {
|
async function renderPage(container, options = {}) {
|
||||||
const { poll = false } = options;
|
const { poll = false } = options;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -888,33 +1192,47 @@ const DeskServices = (() => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hasPage = Boolean(container.querySelector('.servicos-page'));
|
const hasPage = Boolean(container.querySelector('.servicos-page'));
|
||||||
|
const cached = readDomainsCache();
|
||||||
|
|
||||||
if (poll && hasPage) {
|
if (poll && hasPage) {
|
||||||
try {
|
try {
|
||||||
await loadDomains();
|
await loadDomains({ timeoutMs: DOMAINS_POLL_TIMEOUT_MS });
|
||||||
refreshPanels(container);
|
refreshPanels(container);
|
||||||
setPollStatus(container, '');
|
setPollStatus(container, '');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPollStatus(container, `Actualização falhou: ${e.message}`, true);
|
setPollStatus(container, `Actualização falhou (lista anterior mantida): ${e.message}`, true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPage) {
|
if (!hasPage) {
|
||||||
container.innerHTML = '<p class="loading">A carregar clientes e serviços VM112…</p>';
|
const hydrated = hydrateFromCache();
|
||||||
}
|
if (hydrated) {
|
||||||
try {
|
renderPageShell(container, {
|
||||||
await loadDomains();
|
staleHint: hydrated.fresh
|
||||||
if (_clients.length && !_selectedClientId) {
|
? 'A actualizar lista VM112…'
|
||||||
_selectedClientId = _clients[0].id;
|
: 'Lista em cache — a actualizar VM112…',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p class="loading">A carregar clientes e serviços VM112…</p>';
|
||||||
}
|
}
|
||||||
container.innerHTML = pageHtml();
|
}
|
||||||
bindPage(container);
|
|
||||||
setPollStatus(container, '');
|
try {
|
||||||
} catch (e) {
|
await loadDomains({ timeoutMs: DOMAINS_FETCH_TIMEOUT_MS });
|
||||||
if (hasPage) {
|
if (hasPage) {
|
||||||
setPollStatus(container, `Erro ao carregar VM112: ${e.message}`, true);
|
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;
|
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.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));
|
container.querySelector('#servicos-retry')?.addEventListener('click', () => renderPage(container));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -519,7 +519,8 @@ body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button {
|
.nav button,
|
||||||
|
.nav .nav-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
@ -537,6 +538,11 @@ body {
|
||||||
margin-bottom: 0.02rem;
|
margin-bottom: 0.02rem;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, transform 0.15s;
|
transition: background 0.15s, color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .nav-item:visited {
|
||||||
|
color: rgba(255,255,255,0.88);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon-wrap {
|
.nav-icon-wrap {
|
||||||
|
|
@ -570,7 +576,8 @@ body {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button:hover {
|
.nav button:hover,
|
||||||
|
.nav .nav-item:hover {
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: translateX(1px);
|
transform: translateX(1px);
|
||||||
|
|
@ -592,11 +599,13 @@ body {
|
||||||
0 8px 22px rgba(0,0,0,0.16);
|
0 8px 22px rgba(0,0,0,0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button:hover .nav-icon-wrap {
|
.nav button:hover .nav-icon-wrap,
|
||||||
|
.nav .nav-item:hover .nav-icon-wrap {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item-dashboard .nav-icon-svg { width: 52px; height: 52px; }
|
.nav-item-dashboard .nav-icon-svg { width: 52px; height: 52px; }
|
||||||
|
.nav-item-console .nav-icon-svg { width: 52px; height: 52px; }
|
||||||
.nav-item-overview .nav-icon-svg { width: 50px; height: 50px; }
|
.nav-item-overview .nav-icon-svg { width: 50px; height: 50px; }
|
||||||
.nav-item-tickets .nav-icon-svg { width: 52px; height: 52px; }
|
.nav-item-tickets .nav-icon-svg { width: 52px; height: 52px; }
|
||||||
.nav-item-leads .nav-icon-svg { width: 54px; height: 54px; }
|
.nav-item-leads .nav-icon-svg { width: 54px; height: 54px; }
|
||||||
|
|
@ -2511,12 +2520,53 @@ button.health-card {
|
||||||
|
|
||||||
.modal-dns-highlight,
|
.modal-dns-highlight,
|
||||||
.dns-records-section {
|
.dns-records-section {
|
||||||
|
margin-top: 0.75rem;
|
||||||
border: 1px solid #dbe4f4;
|
border: 1px solid #dbe4f4;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dns-viewer-section .dns-viewer-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-viewer-msg {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--muted, #5c6570);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-viewer-ns-warn {
|
||||||
|
color: #b5651d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-viewer-checks {
|
||||||
|
margin: 0.35rem 0 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-viewer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-source-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #445;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dns-viewer-sub {
|
||||||
|
margin: 0.75rem 0 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Infra 2 — SOC Operations Console (dark, scoped) ── */
|
/* ── Infra 2 — SOC Operations Console (dark, scoped) ── */
|
||||||
.soc-console {
|
.soc-console {
|
||||||
--soc-bg: #0b0f14;
|
--soc-bg: #0b0f14;
|
||||||
|
|
@ -3345,6 +3395,104 @@ button.health-card {
|
||||||
background: #fee2e2;
|
background: #fee2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal purge — loading VM112 detail (Spec 018) */
|
||||||
|
.vm112-load-panel {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.vm112-load-progress {
|
||||||
|
height: 4px;
|
||||||
|
background: #fecaca;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
.vm112-load-progress-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 38%;
|
||||||
|
background: linear-gradient(90deg, #b91c1c, #f87171, #b91c1c);
|
||||||
|
border-radius: 999px;
|
||||||
|
animation: vm112-load-bar 1.35s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes vm112-load-bar {
|
||||||
|
0% { transform: translateX(-110%); }
|
||||||
|
100% { transform: translateX(320%); }
|
||||||
|
}
|
||||||
|
.vm112-load-title {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
.vm112-load-steps {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.vm112-load-step {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.28rem 0 0.28rem 1.35rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
}
|
||||||
|
.vm112-load-step::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.15rem;
|
||||||
|
top: 0.62rem;
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
.vm112-load-step--active {
|
||||||
|
color: #991b1b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.vm112-load-step--active::before {
|
||||||
|
background: #dc2626;
|
||||||
|
animation: vm112-purge-pulse 1s ease infinite;
|
||||||
|
}
|
||||||
|
.vm112-load-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.95em;
|
||||||
|
height: 0.95em;
|
||||||
|
border: 2px solid rgba(185, 28, 28, 0.22);
|
||||||
|
border-top-color: #b91c1c;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: vm112-load-spin 0.65s linear infinite;
|
||||||
|
vertical-align: -0.12em;
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
@keyframes vm112-load-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.btn-loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 9rem;
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.vm112-load-dots::after {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.1em;
|
||||||
|
text-align: left;
|
||||||
|
animation: vm112-load-dots 1.4s steps(4, end) infinite;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
@keyframes vm112-load-dots {
|
||||||
|
0% { content: ''; }
|
||||||
|
25% { content: '.'; }
|
||||||
|
50% { content: '..'; }
|
||||||
|
75%, 100% { content: '...'; }
|
||||||
|
}
|
||||||
|
.vm112-modal-sub-loading {
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
/* Contas — Orquestração VM112 (Spec 017) */
|
/* Contas — Orquestração VM112 (Spec 017) */
|
||||||
.contas-page {
|
.contas-page {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
|
|
@ -3667,7 +3815,7 @@ button.health-card {
|
||||||
}
|
}
|
||||||
.servicos-client-row {
|
.servicos-client-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -3680,6 +3828,17 @@ button.health-card {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.servicos-client-main {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.servicos-client-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.servicos-client-row:hover { background: #f3f6fb; }
|
.servicos-client-row:hover { background: #f3f6fb; }
|
||||||
.servicos-client-row--selected {
|
.servicos-client-row--selected {
|
||||||
background: #e8f0fe;
|
background: #e8f0fe;
|
||||||
|
|
@ -3695,7 +3854,9 @@ button.health-card {
|
||||||
.servicos-client-main strong {
|
.servicos-client-main strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
word-break: break-word;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.servicos-client-main span {
|
.servicos-client-main span {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -3826,8 +3987,103 @@ button.health-card {
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.65rem 0.85rem;
|
||||||
border-bottom: 1px solid #f0ebe3;
|
border-bottom: 1px solid #f0ebe3;
|
||||||
}
|
}
|
||||||
|
.servicos-scope-item--clickable {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.servicos-scope-item--clickable:hover,
|
||||||
|
.servicos-scope-item--clickable:focus-visible {
|
||||||
|
background: #faf6f0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.servicos-scope-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
.servicos-scopes .servicos-scope-item:last-child { border-bottom: none; }
|
.servicos-scopes .servicos-scope-item:last-child { border-bottom: none; }
|
||||||
.servicos-scopes strong { display: block; font-size: 0.85rem; }
|
.servicos-scopes strong { display: block; font-size: 0.85rem; }
|
||||||
|
.servicos-scope-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted, #6b7280);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.servicos-scope-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
.servicos-scope-spec {
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
.servicos-scope-goto {
|
||||||
|
color: #2f6fed;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.servicos-scope-layer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 0.12rem 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.servicos-scope-layer--tenant { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.servicos-scope-layer--app { background: #fce7f3; color: #9d174d; }
|
||||||
|
.servicos-scope-layer--infra { background: #e7e5e4; color: #44403c; }
|
||||||
|
.servicos-blocklist-banner {
|
||||||
|
margin: 0.65rem 0.85rem 0.5rem;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
background: #fff1f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
.servicos-blocklist-banner p { margin: 0.35rem 0 0; }
|
||||||
|
.servicos-blocklist-banner code {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
background: #fee2e2;
|
||||||
|
padding: 0.05rem 0.25rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.servicos-client-badge--protected {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
}
|
||||||
|
.servicos-tile-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.45rem;
|
||||||
|
left: 0.55rem;
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
max-width: 46%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.servicos-tile-layer--tenant { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.servicos-tile-layer--app { background: #fce7f3; color: #9d174d; }
|
||||||
|
.servicos-tile-layer--infra { background: #e7e5e4; color: #44403c; }
|
||||||
.servicos-scopes span { font-size: 0.75rem; color: var(--muted, #6b7280); }
|
.servicos-scopes span { font-size: 0.75rem; color: var(--muted, #6b7280); }
|
||||||
.servicos-scope-hint {
|
.servicos-scope-hint {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
|
|
@ -3865,6 +4121,12 @@ button.health-card {
|
||||||
.purge-history-table tbody tr.purge-history-row:hover {
|
.purge-history-table tbody tr.purge-history-row:hover {
|
||||||
background: rgba(47, 111, 237, 0.06);
|
background: rgba(47, 111, 237, 0.06);
|
||||||
}
|
}
|
||||||
|
.event-auditor-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.event-auditor-row:hover {
|
||||||
|
background: rgba(47, 111, 237, 0.06);
|
||||||
|
}
|
||||||
.purge-history-link {
|
.purge-history-link {
|
||||||
color: var(--accent, #2f6fed);
|
color: var(--accent, #2f6fed);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|
@ -4564,7 +4826,7 @@ button.health-card {
|
||||||
.badge.billing { background: #f59e0b33; color: #fbbf24; font-size: 0.65rem; }
|
.badge.billing { background: #f59e0b33; color: #fbbf24; font-size: 0.65rem; }
|
||||||
.kpi-card--billing-pending .kpi-value { color: #f59e0b; }
|
.kpi-card--billing-pending .kpi-value { color: #f59e0b; }
|
||||||
.kpi-card--billing-active .kpi-value { color: #22c55e; }
|
.kpi-card--billing-active .kpi-value { color: #22c55e; }
|
||||||
.servicos-billing-badge { cursor: pointer; margin-left: 0.35rem; font-size: 0.85rem; opacity: 0.45; }
|
.servicos-billing-badge { cursor: pointer; font-size: 0.85rem; opacity: 0.45; }
|
||||||
.servicos-billing-badge--active { opacity: 1; filter: drop-shadow(0 0 4px #22c55e88); }
|
.servicos-billing-badge--active { opacity: 1; filter: drop-shadow(0 0 4px #22c55e88); }
|
||||||
.migration-job-row { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--border); }
|
.migration-job-row { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--border); }
|
||||||
.migration-gate-ready { color: #22c55e; }
|
.migration-gate-ready { color: #22c55e; }
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>Ligbox Ops — Support Desk</title>
|
<title>Ligbox Ops — Support Desk</title>
|
||||||
<link rel="stylesheet" href="/assets/styles.css?v=20260625pollfix"/>
|
<link rel="stylesheet" href="/assets/styles.css?v=20260625align1"/>
|
||||||
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
|
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
|
||||||
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v10"/>
|
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v10"/>
|
||||||
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
|
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
|
||||||
|
|
@ -215,10 +215,10 @@
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-events"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-events"/></svg></span>
|
||||||
<span class="nav-label">Eventos</span>
|
<span class="nav-label">Eventos</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="https://console.ligbox.com.br" class="nav-item nav-item-console" id="nav-console" target="_blank" rel="noopener noreferrer" title="Console unificado — Tenants + Ops">
|
<button type="button" class="nav-item nav-item-console" id="nav-console" title="Console unificado — Domínio & DNS">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-tenants"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-dashboard"/></svg></span>
|
||||||
<span class="nav-label">Console</span>
|
<span class="nav-label">Console</span>
|
||||||
</a>
|
</button>
|
||||||
<button type="button" data-view="tenants" data-module="tenants" id="nav-tenants" hidden class="nav-item nav-item-tenants">
|
<button type="button" data-view="tenants" data-module="tenants" id="nav-tenants" hidden class="nav-item nav-item-tenants">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-tenants"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-tenants"/></svg></span>
|
||||||
<span class="nav-label">Tenants</span>
|
<span class="nav-label">Tenants</span>
|
||||||
|
|
@ -338,6 +338,7 @@
|
||||||
<button type="button" class="filter-btn active events-webhooks-only" data-source="all" data-kind="event">Todas origens</button>
|
<button type="button" class="filter-btn active events-webhooks-only" data-source="all" data-kind="event">Todas origens</button>
|
||||||
<button type="button" class="filter-btn events-webhooks-only" data-source="vm112-onboard" data-kind="event">Onboard</button>
|
<button type="button" class="filter-btn events-webhooks-only" data-source="vm112-onboard" data-kind="event">Onboard</button>
|
||||||
<button type="button" class="filter-btn events-webhooks-only" data-source="wazuh" data-kind="event">Wazuh</button>
|
<button type="button" class="filter-btn events-webhooks-only" data-source="wazuh" data-kind="event">Wazuh</button>
|
||||||
|
<button type="button" class="filter-btn events-webhooks-only" data-source="desk.purge" data-kind="event">Purge</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="events-content"><p class="loading">Carregando…</p></div>
|
<div id="events-content"><p class="loading">Carregando…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -431,6 +432,19 @@
|
||||||
<div id="purge-history-modal-body" class="modal-body"></div>
|
<div id="purge-history-modal-body" class="modal-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="event-auditor-modal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-close-event-auditor-modal></div>
|
||||||
|
<div class="modal-panel modal-panel-lg" role="dialog" aria-modal="true" aria-labelledby="event-auditor-modal-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="event-auditor-modal-title">Auditor de Eventos</h3>
|
||||||
|
<p id="event-auditor-modal-sub" class="ticket-meta"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-close-event-auditor-modal>Fechar</button>
|
||||||
|
</div>
|
||||||
|
<div id="event-auditor-modal-body" class="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="vm112-domain-modal" class="modal hidden" aria-hidden="true">
|
<div id="vm112-domain-modal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-backdrop" data-close-vm112-modal></div>
|
<div class="modal-backdrop" data-close-vm112-modal></div>
|
||||||
<div class="vm112-modal-shell" id="vm112-modal-shell">
|
<div class="vm112-modal-shell" id="vm112-modal-shell">
|
||||||
|
|
@ -467,15 +481,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/assets/auth.js?v=20260619tickets2"></script>
|
<script src="/assets/auth.js?v=20260625serv1"></script>
|
||||||
<script src="/assets/modules.js?v=20260619tickets2"></script>
|
<script src="/assets/modules.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/billing-ui.js?v=20260619tickets2"></script>
|
<script src="/assets/billing-ui.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/desk-live-stub.js?v=20260619tickets2"></script>
|
<script src="/assets/desk-live-stub.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/servicos.js?v=20260620agentic"></script>
|
<script src="/assets/servicos.js?v=20260625align1"></script>
|
||||||
|
<script src="/assets/app.js?v=20260625scope1"></script>
|
||||||
<script src="/assets/agentic-ops.js?v=20260625pollfix"></script>
|
<script src="/assets/agentic-ops.js?v=20260625pollfix"></script>
|
||||||
<script src="/assets/access-matrix.js?v=20260620am7"></script>
|
<script src="/assets/access-matrix.js?v=20260620am7"></script>
|
||||||
<script src="/assets/app.js?v=20260625hotfix"></script>
|
<script src="/assets/dns-viewer.js?v=20260625handoff1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Feature Specification: Domínios VM112 — Purge & Histórico (017)
|
# Feature Specification: Domínios VM112 — Purge & Histórico (017)
|
||||||
|
|
||||||
**Criado:** 2026-06-16
|
**Criado:** 2026-06-16
|
||||||
**Actualizado:** 2026-06-19 (extensões Spec 026 · Spec 032)
|
**Actualizado:** 2026-06-25 (performance modal + UX Escopo OPS + blocklist UI)
|
||||||
**Solicitado por:** Roger
|
**Solicitado por:** Roger
|
||||||
**Status:** v1 + v2 concluídos · Fase 3 VM112 pendente
|
**Status:** v1 + v2 concluídos · Fase 3 VM112 pendente
|
||||||
**Prioridade:** P1 (testes E2E + padrão de limpeza)
|
**Prioridade:** P1 (testes E2E + padrão de limpeza)
|
||||||
|
|
@ -89,7 +89,7 @@ Técnicos `technician` e `noc` **não** acedem.
|
||||||
4. Domínio ∉ blocklist (`ligbox.com.br`, etc.)
|
4. Domínio ∉ blocklist (`ligbox.com.br`, etc.)
|
||||||
5. Proxy VM112 `POST /api/admin/domains/{domain}/purge` com `X-Api-Key`
|
5. Proxy VM112 `POST /api/admin/domains/{domain}/purge` com `X-Api-Key`
|
||||||
|
|
||||||
**Pós-purge Desk:** apagar `audit_domains`, `webhook_events`, `tickets`, `assist_sessions`, `audit_checks` com referência ao domínio.
|
**Pós-purge Desk:** apagar `audit_domains`, `tickets`, `assist_sessions`, `audit_checks`, `domain_console_scenarios` com referência ao domínio. **Preservar** `webhook_events` e inserir marcador `domain.purged` (`source=desk.purge`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -97,12 +97,35 @@ Técnicos `technician` e `noc` **não** acedem.
|
||||||
|
|
||||||
| Método | Path | Auth |
|
| Método | Path | Auth |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| GET | `/api/admin/domains` | `X-Api-Key` |
|
| GET | `/api/admin/domains` | `X-Api-Key` — resposta inclui `cached`, `cache_age_sec` (2026-06-25) |
|
||||||
| GET | `/api/admin/domains/{domain}` | `X-Api-Key` |
|
| GET | `/api/admin/domains/{domain}` | `X-Api-Key` |
|
||||||
| POST | `/api/admin/domains/{domain}/purge` | `X-Api-Key` |
|
| POST | `/api/admin/domains/{domain}/purge` | `X-Api-Key` |
|
||||||
| GET | `/api/admin/domains/purge-jobs/{job_id}` | `X-Api-Key` _(memória, efémero)_ |
|
| GET | `/api/admin/domains/purge-jobs/{job_id}` | `X-Api-Key` _(memória, efémero)_ |
|
||||||
|
|
||||||
|
### Cache listagem domínios (2026-06-25)
|
||||||
|
|
||||||
|
**Problema:** `list_orchestrated_domains()` chamava `zmprov gad` em **cada** `GET /api/admin/domains` (~5s) — gargalo da página Serviços IaaS no Desk (Spec 018).
|
||||||
|
|
||||||
|
**Implementação VM112** (`deploy/vm112-wizard/perf-domains-list-20260625/`):
|
||||||
|
|
||||||
|
| Cache | TTL | Chave | Invalidação |
|
||||||
|
|-------|-----|-------|-------------|
|
||||||
|
| Lista Carbonio (`gad`) | 90s | `gad_all_domains` | `zmprov cd`, `zmprov dd`, purge concluído |
|
||||||
|
| Lista orquestrada montada | 60s | `orchestrated_domains_list` | idem |
|
||||||
|
| Detalhe domínio (purge modal) | 120s | `domain_detail:{domain}` | purge / dd / cd |
|
||||||
|
| Infra checks (`get_status`) | 90s | `infra_status:{domain}` | idem |
|
||||||
|
| Contas (`zmprov gaa`) | 45s | `accounts_list:{domain}` | purge contas / dd |
|
||||||
|
|
||||||
|
**Optimizações modal purge (2026-06-25):** `get_domain_detail()` executa gaa + infra + Cloudflare **em paralelo** (`ThreadPoolExecutor`). 1.º hit ~16s → 2.º hit **~0,04s**.
|
||||||
|
|
||||||
|
**Ficheiros:** `carbonio_cache.py`, `carbonio.list_all_domains()`, `domain_orchestration.get_domain_detail()`, `infrastructure.get_status()` cache.
|
||||||
|
|
||||||
|
**Desk VM122** mantém cache proxy adicional 60s (`VM112_DOMAINS_CACHE_TTL`) — ver Spec 018 § Performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Purge VM112 (ordem):**
|
**Purge VM112 (ordem):**
|
||||||
|
|
||||||
1. Apagar contas Carbonio (`zmprov da`)
|
1. Apagar contas Carbonio (`zmprov da`)
|
||||||
2. Apagar domínio Carbonio (`zmprov dd`)
|
2. Apagar domínio Carbonio (`zmprov dd`)
|
||||||
3. Remover portal users com `planned_corporate_email` no domínio
|
3. Remover portal users com `planned_corporate_email` no domínio
|
||||||
|
|
@ -272,3 +295,123 @@ A Spec 017 cobre o ciclo completo de purge de domínio VM112:
|
||||||
**Purges registados (exemplo):** `myvexx.com`, `diarissima.com`, `ibytera.com` — visíveis em Eventos → Histórico de purges.
|
**Purges registados (exemplo):** `myvexx.com`, `diarissima.com`, `ibytera.com` — visíveis em Eventos → Histórico de purges.
|
||||||
|
|
||||||
**Próximo passo natural:** Fase 3 no wizard VM112; depois filtros/export no histórico se necessário.
|
**Próximo passo natural:** Fase 3 no wizard VM112; depois filtros/export no histórico se necessário.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Incidente 2026-06-25 — Auditor 404 + purge timeout (Roger)
|
||||||
|
|
||||||
|
### Sintomas reportados
|
||||||
|
|
||||||
|
| # | UI | Mensagem | Domínio / ref |
|
||||||
|
|---|-----|----------|----------------|
|
||||||
|
| 1 | Eventos → Auditor de Eventos | `Erro: Not Found` | webhook · **291** (`domain.purged`, `cenario-demo.ops.ligbox.com.br`) |
|
||||||
|
| 2 | Eventos → Histórico de purges | badge **ERRO**, VM112 **timed out** (33s), Desk **0** | `eplacebets.com` · job `84a78c85d6b6490c` |
|
||||||
|
|
||||||
|
### Diagnóstico
|
||||||
|
|
||||||
|
| Erro | Causa raiz | Impacto real |
|
||||||
|
|------|------------|--------------|
|
||||||
|
| Auditor 404 | Modal chamava detalhe inexistente (`GET /webhooks/events/{id}` ausente); evento **291** estava OK em SQLite | Purge `cenario-demo` **concluído** — falha só de visualização |
|
||||||
|
| Purge timed out | `httpx` poll VM112 com timeout **60s** → excepção `timed out`; job marcado `error` **antes** da fase Desk; `recover` não actuava em jobs `error` | `eplacebets.com` **pode** ter ficado parcial na VM112; Desk **não limpo** |
|
||||||
|
|
||||||
|
### Correções (2026-06-25)
|
||||||
|
|
||||||
|
| Camada | Alteração |
|
||||||
|
|--------|-----------|
|
||||||
|
| **API** | `GET /api/v1/webhooks/events/{event_id}` — detalhe para Auditor |
|
||||||
|
| **API purge** | `VM112_PURGE_HTTP_TIMEOUT=300` (env); poll tolerante a falhas transitórias; auto-recuperação se domínio já ausente na VM112 |
|
||||||
|
| **Jobs** | `recover_job` actua em status `error`; `_execute_job` tenta recuperação automática pós-timeout |
|
||||||
|
| **UI Eventos** | Linhas clicáveis → modal **Auditor de Eventos**; render dedicado `domain.purged`; filtro origem **Purge** |
|
||||||
|
| **UI Histórico** | Labels Desk actualizados (`domain.purged` preserva histórico); botão **Recuperar purge** em jobs com erro |
|
||||||
|
|
||||||
|
### Ficheiros
|
||||||
|
|
||||||
|
| Ficheiro | Alteração |
|
||||||
|
|----------|-----------|
|
||||||
|
| `api/app/main.py` | `GET /webhooks/events/{id}` |
|
||||||
|
| `api/app/vm112_domains.py` | timeout 300s, poll resiliente, recover pós-timeout |
|
||||||
|
| `api/app/vm112_purge_jobs.py` | recover em `error`, auto-recover |
|
||||||
|
| `frontend/index.html` | `event-auditor-modal`, filtro Purge |
|
||||||
|
| `frontend/assets/app.js` | Auditor, purge desk labels, botão recover |
|
||||||
|
|
||||||
|
### Validação pós-deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Evento 291 — deve retornar domain.purged (HTTP 200)
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://desk.ligbox.com.br/api/v1/webhooks/events/291"
|
||||||
|
|
||||||
|
# Recuperar job eplacebets se VM112 já limpou
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"https://desk.ligbox.com.br/api/v1/vm112/purge/jobs/84a78c85d6b6490c/recover"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resultado esperado
|
||||||
|
|
||||||
|
1. Clicar evento **291** → modal mostra utilizador, ferramenta, domínio e payload (sem 404).
|
||||||
|
2. Job `84a78c85d6b6490c` → **Recuperar purge** conclui fase Desk se `eplacebets.com` já não existir na VM112; senão repetir purge após fix de timeout.
|
||||||
|
3. Novos purges longos (Carbonio) não falham aos ~33–60s por timeout HTTP do poll.
|
||||||
|
|
||||||
|
### Resultado pós-deploy (2026-06-25, VM122)
|
||||||
|
|
||||||
|
| Verificação | Resultado |
|
||||||
|
|-------------|-----------|
|
||||||
|
| `GET /webhooks/events/291` | ✅ `domain.purged` · `desk.purge` |
|
||||||
|
| `eplacebets.com` na VM112 | ✅ ausente (purge VM112 efectivo) |
|
||||||
|
| Job `84a78c85d6b6490c` recover | ✅ `status=done`, Desk limpo, marcador `domain.purged` inserido |
|
||||||
|
| Job posterior `e238c1efb7c24d92` | ✅ já estava `done` (retry manual anterior) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Serviços — loading, blocklist, Escopo OPS (2026-06-25)
|
||||||
|
|
||||||
|
Melhorias UX na página **Serviços IaaS** (Spec 018) sem alterar regras de purge.
|
||||||
|
|
||||||
|
### Blocklist — apresentação
|
||||||
|
|
||||||
|
| Domínio | API `purge_blocked` | UI lista clientes | UI modal purge |
|
||||||
|
|---------|---------------------|-------------------|----------------|
|
||||||
|
| `ligbox.com.br` | `true` | badge 🔒 (tooltip) | formulário desactivado |
|
||||||
|
| `itecnologys.com` | `true` | idem | idem |
|
||||||
|
| restantes | `false` | — | senha Root + confirmação |
|
||||||
|
|
||||||
|
**Constante Desk:** `PURGE_BLOCKLIST` em `vm112_domains.py` — espelhada no frontend (`servicos.js`) para badges imediatos antes do detalhe API.
|
||||||
|
|
||||||
|
**Painel Escopo OPS:** banner vermelho quando cliente seleccionado ∈ blocklist, listando domínios protegidos.
|
||||||
|
|
||||||
|
### Modal «Gerir» — loading animado
|
||||||
|
|
||||||
|
Enquanto `GET /api/v1/vm112/domains/{domain}` corre (~10–15s cold):
|
||||||
|
|
||||||
|
| Elemento | Comportamento |
|
||||||
|
|----------|----------------|
|
||||||
|
| Skeleton | Dados da lista clientes (admin portal, Carbonio, site) |
|
||||||
|
| Barra progresso | Indeterminada (CSS `vm112-load-bar`) |
|
||||||
|
| Etapas | Rotação Carbonio → Infra CT114 → Cloudflare |
|
||||||
|
| Botão purge | Spinner + «A carregar…» desactivado |
|
||||||
|
| A11y | `aria-busy`, `aria-live="polite"` |
|
||||||
|
|
||||||
|
### Escopo OPS — cards clicáveis
|
||||||
|
|
||||||
|
Ver Spec **018** § Coluna Escopo OPS (mapa completo camada → Spec → destino).
|
||||||
|
|
||||||
|
**Navegação cross-módulo:** `window.DeskNavigate.go(view, opts)` em `app.js`.
|
||||||
|
|
||||||
|
### Ficheiros (2026-06-25 UX)
|
||||||
|
|
||||||
|
| Ficheiro | Alteração |
|
||||||
|
|----------|-----------|
|
||||||
|
| `frontend/assets/servicos.js` | `OPS_SCOPES` enriquecido, `navigateScope`, blocklist UI, loading modal |
|
||||||
|
| `frontend/assets/styles.css` | `.vm112-load-*`, `.servicos-scope-*`, alinhamento lista clientes |
|
||||||
|
| `frontend/assets/app.js` | `DeskNavigate` |
|
||||||
|
| `frontend/index.html` | cache bust `?v=20260625align1` |
|
||||||
|
|
||||||
|
### Versão deploy VM122 (2026-06-25)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend (sem rebuild — docker cp)
|
||||||
|
docker cp servicos.js ligbox-ops-platform_frontend_1:/usr/share/nginx/html/assets/
|
||||||
|
docker cp styles.css ligbox-ops-platform_frontend_1:/usr/share/nginx/html/assets/
|
||||||
|
docker cp app.js ligbox-ops-platform_frontend_1:/usr/share/nginx/html/assets/
|
||||||
|
docker cp index.html ligbox-ops-platform_frontend_1:/usr/share/nginx/html/
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Feature Specification: Orquestração de Serviços — Cliente & Catálogo (018)
|
# Feature Specification: Orquestração de Serviços — Cliente & Catálogo (018)
|
||||||
|
|
||||||
**Criado:** 2026-06-16
|
**Criado:** 2026-06-16
|
||||||
|
**Actualizado:** 2026-06-25 (performance + Escopo OPS clicável + camadas tiles)
|
||||||
**Solicitado por:** Roger
|
**Solicitado por:** Roger
|
||||||
**Status:** Fase 1 concluída (Desk VM122)
|
**Status:** Fase 1 concluída (Desk VM122)
|
||||||
**Wizard cliente:** inalterado na VM112 — só e-mail (`vm112-mail`)
|
**Wizard cliente:** inalterado na VM112 — só e-mail (`vm112-mail`)
|
||||||
|
|
@ -19,6 +20,85 @@ A página **Serviços** substitui a visão estreita «Contas / lista de domínio
|
||||||
**Fase 2:** API Desk `clients` + `service_instances` em SQLite.
|
**Fase 2:** API Desk `clients` + `service_instances` em SQLite.
|
||||||
**Fase 3:** Registry de wizards por `service_catalog.code` e purge por instância.
|
**Fase 3:** Registry de wizards por `service_catalog.code` e purge por instância.
|
||||||
|
|
||||||
|
### Performance — Serviços IaaS (2026-06-25)
|
||||||
|
|
||||||
|
**Cadeia:** Desk `servicos.js` → VM122 `GET /api/v1/vm112/domains` → VM112 `GET /api/admin/domains` → **`zmprov gad`** (~5s sem cache).
|
||||||
|
|
||||||
|
| Camada | Problema | Mitigação | Deploy |
|
||||||
|
|--------|----------|-----------|--------|
|
||||||
|
| **VM112** | `zmprov gad` a cada pedido | Cache 90s `gad_all_domains` + lista orquestrada 60s; invalidação em `cd`/`dd`/purge | `deploy/vm112-wizard/perf-domains-list-20260625/` ✅ 2026-06-25 |
|
||||||
|
| **VM122 API** | Proxy repetia VM112 lenta | Cache 60s `VM112_DOMAINS_CACHE_TTL` | `vm112_domains.py` ✅ |
|
||||||
|
| **Desk frontend** | Ecrã preso «A carregar…» | `sessionStorage` stale-while-revalidate + dedup fetch | `servicos.js` ✅ |
|
||||||
|
| **Poll** | 30s sobrecarregava VM112 | Poll **90s** em `overview-home` | `app.js` ✅ |
|
||||||
|
| **Sessão** | API restart expulsava user | Rede ≠ logout (`ensureValidSession`) | `auth.js` ✅ |
|
||||||
|
|
||||||
|
**Resultados medidos (2026-06-25):**
|
||||||
|
|
||||||
|
| Pedido | Antes | Depois |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| VM112 `/api/admin/domains` (2.º hit) | ~5–11s | **~0,03s** |
|
||||||
|
| VM112 `/api/admin/domains/{domain}` (2.º hit) | ~15–20s | **~0,04s** |
|
||||||
|
| VM112 detalhe (1.º hit, cold) | ~20s serial | **~16s** paralelo (infra+gaa+CF) |
|
||||||
|
| Desk `/api/v1/vm112/domains` (cache quente) | ~16s | **~0,08s** |
|
||||||
|
| Desk `/api/v1/vm112/domains/{domain}` (cache quente) | ~15s+ | **~3s** (1.º após lista) / **~0,1s** (2.º) |
|
||||||
|
| 1.º hit pós-restart wizard | — | ~6s lista / ~16s modal purge |
|
||||||
|
|
||||||
|
### Modal Purge / «Gerir» (2026-06-25)
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/v1/vm112/domains/{domain}` → VM112 `get_domain_detail()`.
|
||||||
|
|
||||||
|
| Gargalo | Tempo | Mitigação |
|
||||||
|
|---------|-------|-----------|
|
||||||
|
| `get_status()` — SSH CT114, DNS, HTTPS | ~9s | Cache `infra_status:{domain}` 90s |
|
||||||
|
| `zmprov gaa` (contas) | ~3s | Cache `accounts_list:{domain}` 45s |
|
||||||
|
| Cloudflare API | ~1–2s | Paralelo + cache `domain_detail:{domain}` 120s |
|
||||||
|
| UI ecrã branco | UX | **Skeleton** imediato com dados da lista clientes |
|
||||||
|
|
||||||
|
**Frontend:** `servicos.js` — modal abre com dados do cliente seleccionado; purge form activa após load.
|
||||||
|
|
||||||
|
**Loading UX (2026-06-25):** barra indeterminada, etapas VM112 rotativas, botão spinner — ver Spec **017** § UI Serviços loading.
|
||||||
|
|
||||||
|
**Desk API:** cache proxy detalhe 45s (`VM112_DOMAIN_DETAIL_TTL`).
|
||||||
|
|
||||||
|
### Escopo OPS — cards clicáveis (2026-06-25)
|
||||||
|
|
||||||
|
Coluna direita: cada escopo é **card botão** com **camada**, **Spec** e **destino**.
|
||||||
|
|
||||||
|
| ID | Label | Camada | Spec | Destino ao clicar |
|
||||||
|
|----|-------|--------|------|-------------------|
|
||||||
|
| `carbonio` | Carbonio | Aplicação MOSP | 017 | Modal Serviços (detalhe/purge) |
|
||||||
|
| `site` | ligbox-sites | Tenant utilizador | 017 | Modal Serviços |
|
||||||
|
| `portal` | Portal users | Tenant utilizador | 028 | Console › `/admin/dominio?domain=` |
|
||||||
|
| `cloudflare` | Cloudflare | Infraestrutura | 037 | Console › DNS Viewer |
|
||||||
|
| `traefik` | Traefik / SNI | Infraestrutura | 026 | Desk › Infra CODE |
|
||||||
|
| `desk` | Desk OPS | Aplicação MOSP | 017 | Desk › Eventos › tab Purges |
|
||||||
|
|
||||||
|
**Blocklist UI:** clientes `ligbox.com.br` / `itecnologys.com` — badge 🔒 na lista; banner no painel Escopo; purge modal desactivado (`purge_blocked` API).
|
||||||
|
|
||||||
|
**Tiles centro:** badge camada (`Tenant utilizador` · `Aplicação MOSP` · `Infraestrutura`) + badge comercial (`SaaS` / `IaaS`).
|
||||||
|
|
||||||
|
**Navegação:** `window.DeskNavigate.go('events', { eventsTab: 'purges' })` — helper em `app.js`.
|
||||||
|
|
||||||
|
**Cache bust frontend (VM122):** `servicos.js`, `styles.css`, `app.js` → `?v=20260625align1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"domains": [ "..." ],
|
||||||
|
"cached": true,
|
||||||
|
"cache_age_sec": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Regra deploy Roger:**
|
||||||
|
|
||||||
|
1. VM112: `systemctl restart ligbox-wizard` invalida cache wizard (~6s no 1.º Serviços IaaS).
|
||||||
|
2. VM122: preferir `docker cp` sem restart; se restart API, Desk mostra cache browser.
|
||||||
|
3. Purge/create domain invalida cache VM112 automaticamente.
|
||||||
|
|
||||||
|
Ver também Spec **017** § cache listagem domínios.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problema
|
## Problema
|
||||||
|
|
@ -384,8 +464,10 @@ A página Serviços no Desk é o **painel do técnico**; os wizards são **um po
|
||||||
|
|
||||||
### Coluna Escopo OPS
|
### Coluna Escopo OPS
|
||||||
|
|
||||||
|
- **Cards clicáveis** (2026-06-25) — camada (tenant / app / infra), Spec, destino OPS
|
||||||
- Lista dos escopos purge / operação quando serviço seleccionado
|
- Lista dos escopos purge / operação quando serviço seleccionado
|
||||||
- **E-mail Tenant:** 6 escopos Spec 017 (Carbonio → Desk)
|
- **E-mail Tenant:** 6 escopos Spec 017 (Carbonio → Desk) — mapa na tabela § Escopo OPS cards
|
||||||
|
- **Blocklist:** banner + badge 🔒 para `ligbox.com.br`, `itecnologys.com`
|
||||||
- **Futuro:** escopo dinâmico por `service_catalog.purge_scopes_json`
|
- **Futuro:** escopo dinâmico por `service_catalog.purge_scopes_json`
|
||||||
- Indicador visual **quem gere** cada camada (matriz pizza — cliente vs Ligbox)
|
- Indicador visual **quem gere** cada camada (matriz pizza — cliente vs Ligbox)
|
||||||
- Nota: purge requer senha Root no modal (serviços SaaS provisionados)
|
- Nota: purge requer senha Root no modal (serviços SaaS provisionados)
|
||||||
|
|
@ -504,6 +586,10 @@ Um mesmo cliente pode misturar modos por instância de serviço (ex.: e-mail Saa
|
||||||
- [x] Purge remove domínio e actualiza lista (Spec 017)
|
- [x] Purge remove domínio e actualiza lista (Spec 017)
|
||||||
- [x] Tiles futuros visíveis como «Em breve»
|
- [x] Tiles futuros visíveis como «Em breve»
|
||||||
- [x] Escopo OPS visível na coluna direita
|
- [x] Escopo OPS visível na coluna direita
|
||||||
|
- [x] Escopo OPS **cards clicáveis** com camada + Spec + navegação (2026-06-25)
|
||||||
|
- [x] Blocklist visível na UI (lista + banner Escopo + modal)
|
||||||
|
- [x] Modal purge com **loading animado** durante consulta VM112
|
||||||
|
- [x] Tiles com badge **camada** (tenant / app / infra)
|
||||||
- [x] RBAC inalterado
|
- [x] RBAC inalterado
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ Exigem um **código de autorização** gerado pelo **super_admin (root)** em **I
|
||||||
| **Extra auth** | `myvexx.com` (+ lista configurável) | Senha Root + **código autorização** (uso único) |
|
| **Extra auth** | `myvexx.com` (+ lista configurável) | Senha Root + **código autorização** (uso único) |
|
||||||
| **Normal** | restantes | Senha Root + confirmação domínio (Spec 017) |
|
| **Normal** | restantes | Senha Root + confirmação domínio (Spec 017) |
|
||||||
|
|
||||||
|
**UI blocklist (2026-06-25):** badge 🔒 na lista clientes Serviços; banner no painel Escopo OPS; modal purge com `purge_blocked: true` — ver Spec **017** § UI Serviços blocklist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fluxo operacional (conferência)
|
## Fluxo operacional (conferência)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue