# Feature Specification: Domínios VM112 — Purge & Histórico (017)

**Criado:** 2026-06-16  
**Actualizado:** 2026-06-25 (performance modal + UX Escopo OPS + blocklist UI)  
**Solicitado por:** Roger  
**Status:** v1 + v2 concluídos · Fase 3 VM112 pendente  
**Prioridade:** P1 (testes E2E + padrão de limpeza)  
**Sistema:** Desk VM122 + Wizard VM112  
**Módulo:** `vm112-domains`  
**UI purge:** página **Serviços** (Spec 018)  
**UI histórico:** **Eventos → Histórico de purges**

---

## Resumo

Técnicos **Admin** (`super_admin`, `ops_lead`) executam **purge completo** de domínios VM112 (Carbonio, site, portal, Cloudflare, Traefik/SNI, registos Desk) a partir da página **Serviços**, com timeline ao vivo no drawer lateral.

**v2 (2026-06-16):** cada purge fica **persistido** em SQLite e consultável em **Eventos → Histórico de purges** — lista clicável + modal com timeline, utilizador e serviços removidos.

**Uso inicial:** limpar domínios de teste para reentrarem no wizard. **Futuro:** padrão de limpeza de dados por domínio.

---

## Módulo Desk (Spec 015)

| Campo | Valor |
|-------|--------|
| `id` | `vm112-domains` |
| `label` | Domínios VM112 |
| `default_enabled` | `true` |
| `nav_views` | _(vazio — purge na página Serviços, histórico em Eventos)_ |

---

## RBAC

| Acção | Perfis |
|-------|--------|
| Executar purge (Serviços) | `super_admin`, `ops_lead` + **senha Root** |
| Ver histórico de purges (Eventos) | `super_admin`, `ops_lead` |
| Listar / detalhe job purge | `super_admin`, `ops_lead` |

Técnicos `technician` e `noc` **não** acedem.

---

## UI — Serviços (Spec 018)

### Tile E-mail Tenant → modal purge

1. **Resumo** — domínio, mail host, admin portal, contas Carbonio, zona CF
2. **Infra** — passos `get_status()` (Carbonio, DNS, SNI, Traefik)
3. **Contas** — lista e-mails Carbonio
4. **Zona perigosa — Purge** (Admin only)
   - Aviso irreversível
   - Confirmação: digitar domínio exacto
   - Campo **senha Root** (Desk)
   - Botão «Apagar domínio e todos os dados»
5. **Drawer lateral** `vm112-purge-drawer` — timeline em tempo real durante execução

---

## API Desk (VM122)

| Método | Path | Descrição |
|--------|------|-----------|
| GET | `/api/v1/vm112/domains?q=` | Lista domínios orquestrados (proxy VM112) |
| GET | `/api/v1/vm112/domains/{domain}` | Detalhe + infra status |
| POST | `/api/v1/vm112/domains/{domain}/purge/jobs` | **Recomendado** — purge async + polling |
| GET | `/api/v1/vm112/purge/jobs/{job_id}` | Estado / timeline do job |
| POST | `/api/v1/vm112/purge/jobs/{job_id}/recover` | Recuperar job após timeout UI |
| GET | `/api/v1/vm112/purge/jobs?limit=&offset=` | **v2** — lista histórico persistido |
| POST | `/api/v1/vm112/domains/{domain}/purge/stream` | Purge SSE (legado Traefik) |
| POST | `/api/v1/vm112/domains/{domain}/purge` | Purge síncrono (legado) |

**Body purge:**
```json
{
  "confirm_domain": "iofficebooks.com",
  "root_password": "********"
}
```

**Validações purge:**
1. `user.role` ∈ {super_admin, ops_lead}
2. `verify_password(root_password, hash do user root)`
3. `confirm_domain` === domínio (case-insensitive)
4. Domínio ∉ blocklist (`ligbox.com.br`, etc.)
5. Proxy VM112 `POST /api/admin/domains/{domain}/purge` com `X-Api-Key`

**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`).

---

## API VM112

| Método | Path | Auth |
|--------|------|------|
| GET | `/api/admin/domains` | `X-Api-Key` — resposta inclui `cached`, `cache_age_sec` (2026-06-25) |
| GET | `/api/admin/domains/{domain}` | `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)_ |

### 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):**

1. Apagar contas Carbonio (`zmprov da`)
2. Apagar domínio Carbonio (`zmprov dd`)
3. Remover portal users com `planned_corporate_email` no domínio
4. Apagar `/opt/ligbox-sites/domains/{domain}/`
5. Apagar zona Cloudflare (se existir na conta Ibytera)
6. Remover `mail.{domain}` do SNI + routers Traefik (CT114)
7. Apagar logs sessão JSONL com referência ao domínio

---

## Fase 2 — Jobs async + polling (implementado)

`POST /api/v1/vm112/domains/{domain}/purge/jobs` inicia thread em background.  
UI faz polling `GET /api/v1/vm112/purge/jobs/{id}` a cada 2s.

**Motivo:** SSE longo falhava via Traefik (`504` / `Failed to fetch` ~60–79s).  
**Fix nginx Desk:** `proxy_read_timeout 600s` em `frontend/nginx.conf`.

Persistência SQLite (`vm112_purge_jobs`) criada nesta fase — base para v2.

---

## Fase 2 — SSE (implementado, legado)

`POST /api/v1/vm112/domains/{domain}/purge/stream` · `text/event-stream`

| type | Conteúdo |
|------|----------|
| `step` | `{ label, at, status, detail }` |
| `heartbeat` | `{ elapsed }` — cada 5s |
| `error` | purge falhou |
| `done` | `{ desk, vm112, domain }` |

Ordem: validação → VM112 (heartbeat) → passos VM112 → passos Desk → concluído.

---

## v2 — Histórico de purges (implementado 2026-06-16)

### Problema resolvido

| Antes | Depois |
|-------|--------|
| Timeline só ao vivo no drawer | Histórico persistente no Desk |
| Dados em SQLite sem UI | Lista + modal de detalhe |
| VM112 jobs em memória (efémero) | Fonte de verdade: VM122 `ops.db` |
| Purges «desapareciam» ao fechar modal | Consulta por domínio, data, utilizador |

**Nota:** purges **antes** da persistência (ex.: `betinsport.com`) não aparecem no histórico.

### UI — Eventos

- Aba **Webhooks** (existente)
- Aba **Histórico de purges** (Admin only)
- Lista: Job ID, domínio, status, utilizador, resumo Desk, data, duração VM112
- Clique na linha → modal com:
  1. Cabeçalho (domínio, status, utilizador, data, job id)
  2. Removido no Desk — webhook_events, tickets, audit_domains, assist_sessions, audit_checks
  3. Removido na VM112 — Carbonio, portal, site, Cloudflare, Traefik
  4. Timeline completa (`timeline_json`)

### Persistência

| Campo | Valor |
|-------|--------|
| Base | `/var/lib/ligbox-ops-platform/ops.db` (Docker: `/data/ops.db`) |
| Tabela | `vm112_purge_jobs` |
| Colunas | `timeline_json`, `desk_json`, `vm112_json`, `by_user`, `status`, `created_at` |

### Ficheiros v2

| Ficheiro | Alteração |
|----------|-----------|
| `api/app/vm112_purge_jobs.py` | `list_jobs()`, schema, persistência |
| `api/app/vm112_domains_routes.py` | `GET /purge/jobs` |
| `frontend/assets/app.js` | `renderPurgeHistory()`, modal, aba Eventos |
| `frontend/index.html` | Toolbar Eventos + `purge-history-modal` |
| `frontend/assets/styles.css` | Estilos lista/modal |

### Critérios de aceitação v2

1. Admin vê aba «Histórico de purges» em Eventos.
2. Lista mostra purges com status, utilizador, data e resumo Desk.
3. Clique abre modal com timeline completa e contagens por serviço.
4. Badges correctos: `done`, `error`, `running`, `queued`.
5. `technician` / `noc` não vêem a aba.

### Consulta manual (SSH VM122)

```bash
sqlite3 /var/lib/ligbox-ops-platform/ops.db \
  "SELECT id, domain, status, by_user, created_at FROM vm112_purge_jobs ORDER BY created_at DESC;"
```

```bash
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://desk.ligbox.com.br/api/v1/vm112/purge/jobs/57845ca1c5c64b53"
```

---

## Extensão — Spec 026 (purge Traefik validation)

Validação YAML + smoke onboard pós-remoção Traefik (CT114). Incidente 2026-06-19: `dynamic.yml` inválido após purge → 404 global no onboard.

**Spec dedicada:** `specs/026-purge-traefik-validation/spec.md`

---

## Extensão — Spec 032 (purge autorização extra)

Domínios em `PURGE_EXTRA_AUTH_DOMAINS` (ex.: `myvexx.com`) exigem **código de autorização** gerado pelo root em **Infra** (senha Root), além da validação purge normal.

| Camada | Regra |
|--------|--------|
| Blocklist | `ligbox.com.br`, `itecnologys.com` — purge proibido |
| Extra auth | Código único + senha Root |
| Normal | Senha Root (Spec 017 v1) |

**Spec dedicada:** `specs/032-purge-domain-extra-auth/spec.md`  
**UI geração:** Infraestrutura → «Códigos autorização purge»  
**UI consumo:** Serviços → modal purge (campo código)

---

## Fase 3 — VM112 passos em tempo real (pendente)

VM112 (`/opt/ligbox-wizard`) emitir passos individuais durante execução (Carbonio, CF, Traefik) em vez de bloco único + heartbeat. Alterações no wizard, não só no Desk.

---

## Critérios de aceitação (v1)

1. Admin executa purge a partir de Serviços.
2. Purge com senha root errada → erro na timeline.
3. Purge com domínio confirmado errado → HTTP 400.
4. Após purge, domínio ausente em Carbonio, ligbox-sites e Desk.
5. Drawer mostra progresso ao vivo; job persiste em SQLite.

---

## Fora de escopo

- Purge parcial (só contas, só DNS)
- Scheduler de limpeza automática
- Export CSV/PDF do histórico
- Filtro por domínio/data na lista de histórico
- Retenção automática / purge de jobs antigos
- Link directo Serviços → histórico do domínio

---

## Conclusão (2026-06-16)

A Spec 017 cobre o ciclo completo de purge de domínio VM112:

| Fase | Entrega | Estado |
|------|---------|--------|
| v1 | Purge completo via Serviços + validação Root | ✅ |
| Fase 2 | Jobs async, polling, persistência SQLite | ✅ |
| Fase 2 SSE | Timeline drawer (legado) | ✅ |
| **v2** | Histórico em Eventos — lista + modal audit trail | ✅ |
| **032** | Códigos autorização extra (myvexx.com) — Infra + Serviços | ✅ |
| **026** | Validação Traefik pós-purge | ✅ |
| Fase 3 | Passos VM112 em tempo real no wizard | ⏳ |

**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.

---

## 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/
```
