Compare commits
4 commits
41c0c2d428
...
50085b7d94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50085b7d94 | ||
|
|
3ee63b3018 | ||
|
|
68ec7bc901 | ||
|
|
7dfdf5bc43 |
19 changed files with 2445 additions and 123 deletions
38
projects/ops-desk/api/app/infra_stack_routes.py
Normal file
38
projects/ops-desk/api/app/infra_stack_routes.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Rotas INFRA CODE — stack completo VMs 112/114/122/123/130."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app import auth, integration_health
|
||||||
|
from app.stack_health import run_stack_health
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/infra", tags=["infra-stack"])
|
||||||
|
|
||||||
|
|
||||||
|
def _can_stack(user: auth.DeskUser) -> bool:
|
||||||
|
return user.role in ("super_admin", "devops", "developer")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stack/status")
|
||||||
|
def infra_stack_status(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not _can_stack(user):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
data = run_stack_health()
|
||||||
|
try:
|
||||||
|
with auth.db() as conn:
|
||||||
|
health = integration_health.build_health_report(conn)
|
||||||
|
onboard = health.get("vm112_onboard") or {}
|
||||||
|
vm_ok = onboard.get("vm112_api", {}).get("reachable")
|
||||||
|
for vm in data["vms"]:
|
||||||
|
if vm["vm"] != "122":
|
||||||
|
continue
|
||||||
|
for svc in vm["services"]:
|
||||||
|
if svc["id"] == "vm122-webhook-soc":
|
||||||
|
svc["ok"] = health.get("status") in ("ok", "degraded") and vm_ok
|
||||||
|
svc["status"] = health.get("status") or "check"
|
||||||
|
gap = onboard.get("gap_minutes")
|
||||||
|
svc["detail"] = f"integração {health.get('status')} · gap {gap if gap is not None else '—'} min"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return data
|
||||||
|
|
@ -26,6 +26,8 @@ from app.carbonio_release_routes import router as carbonio_release_router
|
||||||
from app.migration.router import router as migration_router
|
from app.migration.router import router as migration_router
|
||||||
from app.billing_routes import router as billing_router
|
from app.billing_routes import router as billing_router
|
||||||
from app.security_routes import router as security_router
|
from app.security_routes import router as security_router
|
||||||
|
from app.infra_stack_routes import router as infra_stack_router
|
||||||
|
from app.vm123.routes import router as vm123_router
|
||||||
from app.collectors.base import run_audit
|
from app.collectors.base import run_audit
|
||||||
from app.permissions import (
|
from app.permissions import (
|
||||||
can_assign_ticket,
|
can_assign_ticket,
|
||||||
|
|
@ -129,6 +131,8 @@ app.include_router(security_router)
|
||||||
app.include_router(carbonio_release_router)
|
app.include_router(carbonio_release_router)
|
||||||
app.include_router(migration_router)
|
app.include_router(migration_router)
|
||||||
app.include_router(billing_router)
|
app.include_router(billing_router)
|
||||||
|
app.include_router(infra_stack_router)
|
||||||
|
app.include_router(vm123_router)
|
||||||
|
|
||||||
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
|
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,14 @@ MODULES: tuple[ModuleDef, ...] = (
|
||||||
),
|
),
|
||||||
ModuleDef(
|
ModuleDef(
|
||||||
id="overview-home",
|
id="overview-home",
|
||||||
label="Serviços",
|
label="Serviços IaaS",
|
||||||
description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018).",
|
description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018) · Infra as Code.",
|
||||||
nav_views=("overview-home",),
|
nav_views=("overview-home",),
|
||||||
),
|
),
|
||||||
ModuleDef(
|
ModuleDef(
|
||||||
id="infra",
|
id="infra",
|
||||||
label="Infra",
|
label="INFRA CODE",
|
||||||
description="Health VM112, VM104 e integrações técnicas.",
|
description="Infrastructure as Code — stack VMs 112/114/122/123/130 (Spec 033).",
|
||||||
nav_views=("infra",),
|
nav_views=("infra",),
|
||||||
),
|
),
|
||||||
ModuleDef(
|
ModuleDef(
|
||||||
|
|
|
||||||
447
projects/ops-desk/api/app/stack_health.py
Normal file
447
projects/ops-desk/api/app/stack_health.py
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
"""Stack health probes — VMs 112, 114, 122, 123, 130 (Spec 033 / INFRA CODE)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
VM112_IP = os.getenv("VM112_IP", "10.10.10.112")
|
||||||
|
VM112_API = os.getenv("VM112_API_URL", f"http://{VM112_IP}:8090").rstrip("/")
|
||||||
|
DESK_PUBLIC = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br").rstrip("/")
|
||||||
|
API_PUBLIC = os.getenv("API_PUBLIC_URL", "https://api.ops.ligbox.com.br").rstrip("/")
|
||||||
|
VM114_IP = os.getenv("VM114_IP", "10.10.10.114")
|
||||||
|
VM123_IP = os.getenv("VM123_IP", "10.10.10.123")
|
||||||
|
VM130_IP = os.getenv("VM130_IP", "10.10.10.130")
|
||||||
|
OPENPANEL_BRIDGE = os.getenv("OPENPANEL_BRIDGE_URL", f"http://{VM123_IP}:18087").rstrip("/")
|
||||||
|
OPENPANEL_BRIDGE_TOKEN = os.getenv("OPENPANEL_BRIDGE_TOKEN", "")
|
||||||
|
TRAEFIK_API = os.getenv("TRAEFIK_API_URL", f"http://{VM114_IP}:8080")
|
||||||
|
|
||||||
|
ProbeFn = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_http(
|
||||||
|
*,
|
||||||
|
url: str,
|
||||||
|
timeout: float = 8.0,
|
||||||
|
verify: bool = True,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
expect_status: tuple[int, ...] = (200),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout, verify=verify, follow_redirects=True) as client:
|
||||||
|
res = client.get(url, headers=headers or {})
|
||||||
|
ok = res.status_code in expect_status
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"status": "online" if ok else "check",
|
||||||
|
"http_status": res.status_code,
|
||||||
|
"detail": f"HTTP {res.status_code}",
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "status": "down", "http_status": None, "detail": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_redis(redis_url: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
|
||||||
|
r = redis.from_url(redis_url)
|
||||||
|
r.ping()
|
||||||
|
return {"ok": True, "status": "online", "detail": "PING OK"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "status": "down", "detail": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_stack_catalog() -> list[dict[str, Any]]:
|
||||||
|
"""Catálogo estático — apps, APIs e software do stack Ligbox."""
|
||||||
|
redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"vm": "112",
|
||||||
|
"vm_label": "VM112 · Wizard & Mail",
|
||||||
|
"ip": "10.10.10.112",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm112-onboard-api",
|
||||||
|
"title": "Onboard Portal API",
|
||||||
|
"spec": "001",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🌐",
|
||||||
|
"accent": "aqua",
|
||||||
|
"url": f"{VM112_API}/api/onboarding/health",
|
||||||
|
"probe": lambda: _probe_http(url=f"{VM112_API}/api/onboarding/health"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm112-onboard-ui",
|
||||||
|
"title": "Onboard Wizard UI",
|
||||||
|
"spec": "025",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🧭",
|
||||||
|
"accent": "aqua",
|
||||||
|
"url": "https://onboard.ligbox.com.br",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url="https://onboard.ligbox.com.br/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm112-carbonio",
|
||||||
|
"title": "Carbonio EmailServer",
|
||||||
|
"spec": "022",
|
||||||
|
"kind": "sw",
|
||||||
|
"icon": "✉️",
|
||||||
|
"accent": "slate",
|
||||||
|
"url": f"https://{VM112_IP}:443",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"https://{VM112_IP}/",
|
||||||
|
verify=False,
|
||||||
|
expect_status=(200, 301, 302, 403, 404),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm112-domain-api",
|
||||||
|
"title": "Domain Admin API",
|
||||||
|
"spec": "017",
|
||||||
|
"kind": "api",
|
||||||
|
"icon": "🗂️",
|
||||||
|
"accent": "aqua",
|
||||||
|
"url": f"{VM112_API}/api/admin/domains",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"{VM112_API}/api/onboarding/health",
|
||||||
|
expect_status=(200),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vm": "114",
|
||||||
|
"vm_label": "CT114 · Edge Traefik",
|
||||||
|
"ip": VM114_IP,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm114-traefik",
|
||||||
|
"title": "Traefik API",
|
||||||
|
"spec": "026",
|
||||||
|
"kind": "sw",
|
||||||
|
"icon": "🚦",
|
||||||
|
"accent": "slate",
|
||||||
|
"url": f"{TRAEFIK_API}/api/overview",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"{TRAEFIK_API}/api/overview",
|
||||||
|
expect_status=(200, 401, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm114-desk-route",
|
||||||
|
"title": "Router Desk (WAN)",
|
||||||
|
"spec": "026",
|
||||||
|
"kind": "integration",
|
||||||
|
"icon": "🔗",
|
||||||
|
"accent": "teal",
|
||||||
|
"url": DESK_PUBLIC,
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"{DESK_PUBLIC}/",
|
||||||
|
expect_status=(200, 301, 302),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm114-api-route",
|
||||||
|
"title": "Router API Ops (WAN)",
|
||||||
|
"spec": "027",
|
||||||
|
"kind": "integration",
|
||||||
|
"icon": "⚙️",
|
||||||
|
"accent": "teal",
|
||||||
|
"url": f"{API_PUBLIC}/health",
|
||||||
|
"probe": lambda: _probe_http(url=f"{API_PUBLIC}/health"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vm": "122",
|
||||||
|
"vm_label": "VM122 · Ops Desk",
|
||||||
|
"ip": "10.10.10.122",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm122-desk-api",
|
||||||
|
"title": "Desk API (FastAPI)",
|
||||||
|
"spec": "003",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "⚙️",
|
||||||
|
"accent": "teal",
|
||||||
|
"url": "http://127.0.0.1:8080/health",
|
||||||
|
"probe": lambda: _probe_http(url="http://127.0.0.1:8080/health"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm122-desk-ui",
|
||||||
|
"title": "Desk Frontend",
|
||||||
|
"spec": "033",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🖥️",
|
||||||
|
"accent": "teal",
|
||||||
|
"url": "http://10.10.10.122:8091",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url="http://10.10.10.122:8091/",
|
||||||
|
expect_status=(200, 301, 302),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm122-redis",
|
||||||
|
"title": "Redis",
|
||||||
|
"spec": "—",
|
||||||
|
"kind": "sw",
|
||||||
|
"icon": "🧠",
|
||||||
|
"accent": "slate",
|
||||||
|
"url": redis_url,
|
||||||
|
"probe": lambda: _probe_redis(redis_url),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm122-webhook-soc",
|
||||||
|
"title": "Webhook SOC VM112",
|
||||||
|
"spec": "001",
|
||||||
|
"kind": "integration",
|
||||||
|
"icon": "📡",
|
||||||
|
"accent": "teal",
|
||||||
|
"url": "/api/v1/integrations/health",
|
||||||
|
"probe": lambda: {"ok": True, "status": "online", "detail": "via integrations/health"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm122-purge-auth",
|
||||||
|
"title": "API Purge Auth",
|
||||||
|
"spec": "032",
|
||||||
|
"kind": "api",
|
||||||
|
"icon": "🔐",
|
||||||
|
"accent": "rose",
|
||||||
|
"url": "/api/v1/infra/purge-auth-domains",
|
||||||
|
"probe": lambda: {"ok": True, "status": "online", "detail": "módulo local"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vm": "123",
|
||||||
|
"vm_label": "VM123 · Finance & Hosting",
|
||||||
|
"ip": VM123_IP,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm123-foss",
|
||||||
|
"title": "FOSSBilling",
|
||||||
|
"spec": "024",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "💰",
|
||||||
|
"accent": "orange",
|
||||||
|
"url": f"http://{VM123_IP}:8092",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"http://{VM123_IP}:8092/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-odoo",
|
||||||
|
"title": "Odoo 16",
|
||||||
|
"spec": "024",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "📊",
|
||||||
|
"accent": "violet",
|
||||||
|
"url": f"http://{VM123_IP}:8069",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"http://{VM123_IP}:8069/web/login",
|
||||||
|
expect_status=(200, 301, 302),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-openpanel-ui",
|
||||||
|
"title": "OpenPanel CE",
|
||||||
|
"spec": "028",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🎛️",
|
||||||
|
"accent": "orange",
|
||||||
|
"url": "https://openpanel.ligbox.com.br",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url="https://openpanel.ligbox.com.br/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-openadmin",
|
||||||
|
"title": "OpenAdmin",
|
||||||
|
"spec": "024",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🔧",
|
||||||
|
"accent": "orange",
|
||||||
|
"url": "https://admin.openpanel.ligbox.com.br",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url="https://admin.openpanel.ligbox.com.br/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-openpanel-bridge",
|
||||||
|
"title": "OpenPanel FOSS Bridge",
|
||||||
|
"spec": "028",
|
||||||
|
"kind": "api",
|
||||||
|
"icon": "🌉",
|
||||||
|
"accent": "orange",
|
||||||
|
"url": f"{OPENPANEL_BRIDGE}/api",
|
||||||
|
"probe": _probe_openpanel_bridge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-ops-console",
|
||||||
|
"title": "Ops Console UI",
|
||||||
|
"spec": "019",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🖥️",
|
||||||
|
"accent": "aqua",
|
||||||
|
"url": f"http://{VM123_IP}:8100/health",
|
||||||
|
"probe": lambda: _probe_http(url=f"http://{VM123_IP}:8100/health"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-phpmyadmin",
|
||||||
|
"title": "phpMyAdmin",
|
||||||
|
"spec": "—",
|
||||||
|
"kind": "sw",
|
||||||
|
"icon": "🗄️",
|
||||||
|
"accent": "slate",
|
||||||
|
"url": f"http://{VM123_IP}:8888",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"http://{VM123_IP}:8888/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm123-ollama",
|
||||||
|
"title": "Ollama LLM",
|
||||||
|
"spec": "029",
|
||||||
|
"kind": "api",
|
||||||
|
"icon": "🤖",
|
||||||
|
"accent": "violet",
|
||||||
|
"url": f"http://{VM123_IP}:11434/api/tags",
|
||||||
|
"probe": lambda: _probe_http(url=f"http://{VM123_IP}:11434/api/tags"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vm": "130",
|
||||||
|
"vm_label": "CT130 · Spec Hub",
|
||||||
|
"ip": VM130_IP,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"id": "vm130-forgejo",
|
||||||
|
"title": "Forgejo Git",
|
||||||
|
"spec": "031",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "🦊",
|
||||||
|
"accent": "violet",
|
||||||
|
"url": f"http://{VM130_IP}:3000",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url=f"http://{VM130_IP}:3000/",
|
||||||
|
expect_status=(200, 301, 302, 403),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm130-spec-portal",
|
||||||
|
"title": "Spec Portal",
|
||||||
|
"spec": "031",
|
||||||
|
"kind": "app",
|
||||||
|
"icon": "📚",
|
||||||
|
"accent": "aqua",
|
||||||
|
"url": f"http://{VM130_IP}:8080",
|
||||||
|
"probe": lambda: _probe_http(url=f"http://{VM130_IP}:8080/"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vm130-spec-public",
|
||||||
|
"title": "Spec Hub (WAN)",
|
||||||
|
"spec": "031",
|
||||||
|
"kind": "integration",
|
||||||
|
"icon": "🔗",
|
||||||
|
"accent": "violet",
|
||||||
|
"url": "https://spec.ligbox.com.br",
|
||||||
|
"probe": lambda: _probe_http(
|
||||||
|
url="https://spec.ligbox.com.br/",
|
||||||
|
expect_status=(200, 301, 302),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_openpanel_bridge() -> dict[str, Any]:
|
||||||
|
if not OPENPANEL_BRIDGE_TOKEN:
|
||||||
|
return {"ok": False, "status": "check", "detail": "OPENPANEL_BRIDGE_TOKEN ausente"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.get(
|
||||||
|
f"{OPENPANEL_BRIDGE}/api",
|
||||||
|
headers={"Authorization": f"Bearer {OPENPANEL_BRIDGE_TOKEN}"},
|
||||||
|
)
|
||||||
|
ok = res.status_code < 400
|
||||||
|
return {"ok": ok, "status": "online" if ok else "check", "http_status": res.status_code, "detail": f"HTTP {res.status_code}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "status": "down", "detail": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_stack_health() -> dict[str, Any]:
|
||||||
|
catalog = build_stack_catalog()
|
||||||
|
vm_out: list[dict[str, Any]] = []
|
||||||
|
total = ok_count = 0
|
||||||
|
|
||||||
|
def run_one(vm_block: dict[str, Any], svc: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
probe = svc.get("probe")
|
||||||
|
result = probe() if callable(probe) else {"ok": False, "status": "unknown", "detail": "sem probe"}
|
||||||
|
row = {
|
||||||
|
"id": svc["id"],
|
||||||
|
"title": svc["title"],
|
||||||
|
"spec": svc["spec"],
|
||||||
|
"kind": svc["kind"],
|
||||||
|
"icon": svc["icon"],
|
||||||
|
"accent": svc["accent"],
|
||||||
|
"url": svc["url"],
|
||||||
|
"ok": bool(result.get("ok")),
|
||||||
|
"status": result.get("status") or ("online" if result.get("ok") else "down"),
|
||||||
|
"http_status": result.get("http_status"),
|
||||||
|
"detail": result.get("detail") or "",
|
||||||
|
}
|
||||||
|
return vm_block, row
|
||||||
|
|
||||||
|
futures = []
|
||||||
|
with ThreadPoolExecutor(max_workers=12) as pool:
|
||||||
|
for vm_block in catalog:
|
||||||
|
for svc in vm_block["services"]:
|
||||||
|
futures.append(pool.submit(run_one, vm_block, svc))
|
||||||
|
|
||||||
|
vm_map: dict[str, dict[str, Any]] = {}
|
||||||
|
for vm_block in catalog:
|
||||||
|
vm_map[vm_block["vm"]] = {
|
||||||
|
"vm": vm_block["vm"],
|
||||||
|
"vm_label": vm_block["vm_label"],
|
||||||
|
"ip": vm_block["ip"],
|
||||||
|
"services": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
vm_block, row = fut.result()
|
||||||
|
vm_map[vm_block["vm"]]["services"].append(row)
|
||||||
|
total += 1
|
||||||
|
if row["ok"]:
|
||||||
|
ok_count += 1
|
||||||
|
|
||||||
|
for vm_id in vm_map:
|
||||||
|
vm_map[vm_id]["services"].sort(key=lambda s: s["title"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"generated_at": _now(),
|
||||||
|
"summary": {
|
||||||
|
"total": total,
|
||||||
|
"ok": ok_count,
|
||||||
|
"degraded": total - ok_count,
|
||||||
|
"vms": list(vm_map.keys()),
|
||||||
|
},
|
||||||
|
"vms": [vm_map[k] for k in sorted(vm_map.keys())],
|
||||||
|
}
|
||||||
5
projects/ops-desk/api/app/vm123/__init__.py
Normal file
5
projects/ops-desk/api/app/vm123/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""VM123 integration — Spec 027 Fase 3."""
|
||||||
|
|
||||||
|
from app.vm123.routes import router as vm123_router
|
||||||
|
|
||||||
|
__all__ = ["vm123_router"]
|
||||||
98
projects/ops-desk/api/app/vm123/foss_client.py
Normal file
98
projects/ops-desk/api/app/vm123/foss_client.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Cliente FOSSBilling Admin API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.vm123.role_map import FOSS_GROUP_BY_ROLE
|
||||||
|
|
||||||
|
FOSS_BASE = os.getenv("FOSSBILLING_URL", "https://financeiro.ligbox.com.br").rstrip("/")
|
||||||
|
FOSS_ADMIN_USER = os.getenv("FOSS_ADMIN_USER", "admin")
|
||||||
|
FOSS_ADMIN_API_KEY = os.getenv("FOSS_ADMIN_API_KEY", os.getenv("FOSS_API_KEY", ""))
|
||||||
|
FOSS_PUBLIC_ADMIN = os.getenv("FOSS_PUBLIC_ADMIN_URL", f"{FOSS_BASE}/admin")
|
||||||
|
|
||||||
|
|
||||||
|
class FossConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _configured() -> bool:
|
||||||
|
return bool(FOSS_ADMIN_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth():
|
||||||
|
if not _configured():
|
||||||
|
raise FossConfigError("FOSS_ADMIN_API_KEY não configurado no Desk")
|
||||||
|
return (FOSS_ADMIN_USER, FOSS_ADMIN_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, payload: dict) -> dict[str, Any]:
|
||||||
|
url = f"{FOSS_BASE}/api/admin/{path.lstrip('/')}"
|
||||||
|
with httpx.Client(timeout=20.0) as client:
|
||||||
|
res = client.post(url, json=payload, auth=_auth())
|
||||||
|
if res.status_code >= 400:
|
||||||
|
raise RuntimeError(f"FOSS {path} HTTP {res.status_code}: {res.text[:300]}")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except Exception:
|
||||||
|
return {"raw": res.text}
|
||||||
|
|
||||||
|
|
||||||
|
def find_client_by_email(email: str) -> dict[str, Any] | None:
|
||||||
|
data = _post("client/get_list", {"per_page": 50, "search": email.strip()})
|
||||||
|
items = data.get("result", {}).get("list") if isinstance(data.get("result"), dict) else data.get("list")
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
needle = email.strip().lower()
|
||||||
|
for item in items:
|
||||||
|
if str(item.get("email", "")).lower() == needle:
|
||||||
|
return item
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_client_by_domain(domain: str) -> dict[str, Any] | None:
|
||||||
|
dom = domain.strip().lower()
|
||||||
|
data = _post("client/get_list", {"per_page": 100})
|
||||||
|
items = data.get("result", {}).get("list") if isinstance(data.get("result"), dict) else data.get("list") or []
|
||||||
|
for item in items:
|
||||||
|
for field in ("company", "company_vat", "email"):
|
||||||
|
val = str(item.get(field, "")).lower()
|
||||||
|
if dom in val:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def staff_group_name_for_role(desk_role: str) -> str | None:
|
||||||
|
return FOSS_GROUP_BY_ROLE.get(desk_role)
|
||||||
|
|
||||||
|
|
||||||
|
def create_staff(*, email: str, name: str, desk_role: str, password: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Cria staff FOSS — grupo staff deve existir no Admin (manual v1)."""
|
||||||
|
group_name = staff_group_name_for_role(desk_role)
|
||||||
|
if not group_name:
|
||||||
|
return {"skipped": True, "reason": f"role {desk_role} sem grupo FOSS"}
|
||||||
|
pwd = password or secrets.token_urlsafe(14)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"email": email.strip().lower(),
|
||||||
|
"name": name,
|
||||||
|
"password": pwd,
|
||||||
|
"status": "active",
|
||||||
|
"admin_group_id": group_name,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = _post("staff/create", payload)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "admin_group" in str(exc).lower() or "group" in str(exc).lower():
|
||||||
|
return {"skipped": True, "reason": str(exc), "group": group_name}
|
||||||
|
raise
|
||||||
|
return {
|
||||||
|
"foss_staff_id": result.get("id") or result.get("result"),
|
||||||
|
"email": email,
|
||||||
|
"group": group_name,
|
||||||
|
"admin_url": FOSS_PUBLIC_ADMIN,
|
||||||
|
"created": True,
|
||||||
|
}
|
||||||
240
projects/ops-desk/api/app/vm123/odoo_client.py
Normal file
240
projects/ops-desk/api/app/vm123/odoo_client.py
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
"""Cliente Odoo 16 XML-RPC — atribuição de perfis via res.groups / res.users."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import xmlrpc.client
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.vm123.role_map import DESK_ROLE_ODOO_GROUP_NAMES, DESK_ROLE_ODOO_XMLIDS
|
||||||
|
|
||||||
|
ODOO_URL = os.getenv("ODOO_URL", "http://10.10.10.123:8069").rstrip("/")
|
||||||
|
ODOO_DB = os.getenv("ODOO_DB", "ligbox")
|
||||||
|
ODOO_LOGIN = os.getenv("ODOO_LOGIN", "admin@ligbox.com.br")
|
||||||
|
ODOO_API_KEY = os.getenv("ODOO_API_KEY", os.getenv("ODOO_PASSWORD", ""))
|
||||||
|
ODOO_PUBLIC_URL = os.getenv(
|
||||||
|
"ODOO_PUBLIC_URL",
|
||||||
|
"https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OdooConfigError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OdooProvisionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _configured() -> bool:
|
||||||
|
return bool(ODOO_API_KEY and ODOO_LOGIN and ODOO_DB)
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
if not _configured():
|
||||||
|
raise OdooConfigError("ODOO_LOGIN / ODOO_API_KEY não configurados no Desk")
|
||||||
|
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common", allow_none=True)
|
||||||
|
uid = common.authenticate(ODOO_DB, ODOO_LOGIN, ODOO_API_KEY, {})
|
||||||
|
if not uid:
|
||||||
|
raise OdooConfigError("falha autenticação Odoo — verifique credenciais")
|
||||||
|
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object", allow_none=True)
|
||||||
|
return uid, models
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_xmlid(uid: int, models, xmlid: str) -> int | None:
|
||||||
|
if "." not in xmlid:
|
||||||
|
return None
|
||||||
|
module, name = xmlid.split(".", 1)
|
||||||
|
rows = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"ir.model.data",
|
||||||
|
"search_read",
|
||||||
|
[[("module", "=", module), ("name", "=", name)]],
|
||||||
|
{"fields": ["res_id"], "limit": 1},
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return int(rows[0]["res_id"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_group_names(uid: int, models, names: tuple[str, ...]) -> list[int]:
|
||||||
|
ids: list[int] = []
|
||||||
|
for label in names:
|
||||||
|
rows = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.groups",
|
||||||
|
"search_read",
|
||||||
|
[[("full_name", "=", label)]],
|
||||||
|
{"fields": ["id"], "limit": 1},
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
rows = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.groups",
|
||||||
|
"search_read",
|
||||||
|
[[("name", "=", label)]],
|
||||||
|
{"fields": ["id"], "limit": 1},
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
ids.append(int(rows[0]["id"]))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def group_ids_for_desk_role(role: str) -> list[int]:
|
||||||
|
"""Resolve group IDs Odoo para função Desk. Levanta se apps não instaladas."""
|
||||||
|
uid, models = _client()
|
||||||
|
xmlids = DESK_ROLE_ODOO_XMLIDS.get(role, ())
|
||||||
|
group_ids: list[int] = []
|
||||||
|
missing_xmlids: list[str] = []
|
||||||
|
for xid in xmlids:
|
||||||
|
gid = _resolve_xmlid(uid, models, xid)
|
||||||
|
if gid:
|
||||||
|
group_ids.append(gid)
|
||||||
|
else:
|
||||||
|
missing_xmlids.append(xid)
|
||||||
|
if group_ids:
|
||||||
|
return group_ids
|
||||||
|
# fallback por nome
|
||||||
|
names = DESK_ROLE_ODOO_GROUP_NAMES.get(role, ())
|
||||||
|
group_ids = _resolve_group_names(uid, models, names)
|
||||||
|
if group_ids:
|
||||||
|
return group_ids
|
||||||
|
hint = ", ".join(missing_xmlids) or role
|
||||||
|
raise OdooProvisionError(
|
||||||
|
f"grupos Odoo não encontrados para role={role} ({hint}). "
|
||||||
|
"Instale apps Sales/Accounting no Odoo ou crie grupos custom."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_role_model(role: str) -> dict[str, Any]:
|
||||||
|
"""Introspecção — grupos mapeados e estado das apps (para Roger / debug)."""
|
||||||
|
if not _configured():
|
||||||
|
return {"configured": False, "role": role, "groups": [], "note": "ODOO_API_KEY ausente"}
|
||||||
|
uid, models = _client()
|
||||||
|
xmlids = DESK_ROLE_ODOO_XMLIDS.get(role, ())
|
||||||
|
resolved = []
|
||||||
|
for xid in xmlids:
|
||||||
|
gid = _resolve_xmlid(uid, models, xid)
|
||||||
|
item: dict[str, Any] = {"xmlid": xid, "group_id": gid}
|
||||||
|
if gid:
|
||||||
|
g = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.groups",
|
||||||
|
"read",
|
||||||
|
[[gid]],
|
||||||
|
{"fields": ["name", "full_name"]},
|
||||||
|
)[0]
|
||||||
|
item["name"] = g.get("full_name") or g.get("name")
|
||||||
|
else:
|
||||||
|
item["missing"] = True
|
||||||
|
resolved.append(item)
|
||||||
|
installed = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"ir.module.module",
|
||||||
|
"search_read",
|
||||||
|
[[("name", "in", ["sale", "sale_management", "account", "crm"]), ("state", "=", "installed")]],
|
||||||
|
{"fields": ["name", "state"], "limit": 20},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"configured": True,
|
||||||
|
"role": role,
|
||||||
|
"db": ODOO_DB,
|
||||||
|
"public_url": ODOO_PUBLIC_URL,
|
||||||
|
"groups": resolved,
|
||||||
|
"installed_sales_account_modules": [m["name"] for m in installed],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_partner_by_email(email: str) -> dict[str, Any] | None:
|
||||||
|
uid, models = _client()
|
||||||
|
rows = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.partner",
|
||||||
|
"search_read",
|
||||||
|
[[("email", "=ilike", email.strip())]],
|
||||||
|
{"fields": ["id", "name", "email", "vat"], "limit": 1},
|
||||||
|
)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_login(login: str) -> dict[str, Any] | None:
|
||||||
|
uid, models = _client()
|
||||||
|
rows = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.users",
|
||||||
|
"search_read",
|
||||||
|
[[("login", "=", login.strip().lower())]],
|
||||||
|
{"fields": ["id", "name", "login", "groups_id"], "limit": 1},
|
||||||
|
)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_internal_user(
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
desk_role: str,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Cria ou actualiza utilizador interno Ligbox com groups_id conforme função Desk."""
|
||||||
|
uid, models = _client()
|
||||||
|
login = email.strip().lower()
|
||||||
|
group_ids = group_ids_for_desk_role(desk_role)
|
||||||
|
# Internal User (base.group_user) — xmlid base.group_user
|
||||||
|
base_user_gid = _resolve_xmlid(uid, models, "base.group_user")
|
||||||
|
if base_user_gid and base_user_gid not in group_ids:
|
||||||
|
group_ids = [base_user_gid, *group_ids]
|
||||||
|
existing = find_user_by_login(login)
|
||||||
|
groups_cmd = [(6, 0, group_ids)]
|
||||||
|
if existing:
|
||||||
|
models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.users",
|
||||||
|
"write",
|
||||||
|
[[existing["id"]], {"name": name, "groups_id": groups_cmd}],
|
||||||
|
)
|
||||||
|
odoo_uid = int(existing["id"])
|
||||||
|
created = False
|
||||||
|
else:
|
||||||
|
pwd = password or secrets.token_urlsafe(16)
|
||||||
|
odoo_uid = models.execute_kw(
|
||||||
|
ODOO_DB,
|
||||||
|
uid,
|
||||||
|
ODOO_API_KEY,
|
||||||
|
"res.users",
|
||||||
|
"create",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"login": login,
|
||||||
|
"email": login,
|
||||||
|
"password": pwd,
|
||||||
|
"groups_id": groups_cmd,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
created = True
|
||||||
|
return {
|
||||||
|
"odoo_uid": odoo_uid,
|
||||||
|
"login": login,
|
||||||
|
"created": created,
|
||||||
|
"group_ids": group_ids,
|
||||||
|
"login_url": ODOO_PUBLIC_URL,
|
||||||
|
}
|
||||||
118
projects/ops-desk/api/app/vm123/openpanel_client.py
Normal file
118
projects/ops-desk/api/app/vm123/openpanel_client.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""OpenPanel Community bridge client."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BRIDGE_URL = os.getenv("OPENPANEL_BRIDGE_URL", "http://10.10.10.123:18087").rstrip("/")
|
||||||
|
BRIDGE_TOKEN = os.getenv("OPENPANEL_BRIDGE_TOKEN", "")
|
||||||
|
OPENADMIN_URL = os.getenv("OPENADMIN_URL", "https://admin.openpanel.ligbox.com.br")
|
||||||
|
OPENPANEL_URL = os.getenv("OPENPANEL_URL", "https://openpanel.ligbox.com.br")
|
||||||
|
DEFAULT_PLAN = os.getenv("OPENPANEL_DEFAULT_PLAN", "ligbox-site-cms")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPanelBridgeError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_configured() -> bool:
|
||||||
|
return bool(BRIDGE_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
if not bridge_configured():
|
||||||
|
raise OpenPanelBridgeError("OPENPANEL_BRIDGE_TOKEN ausente")
|
||||||
|
return {"Authorization": f"Bearer {BRIDGE_TOKEN}"}
|
||||||
|
|
||||||
|
|
||||||
|
def autologin_payload(username: str) -> dict[str, Any]:
|
||||||
|
"""MVP: devolve URL OpenAdmin + instrução CONNECT (Enterprise futuro)."""
|
||||||
|
return {
|
||||||
|
"username": username,
|
||||||
|
"openadmin_url": OPENADMIN_URL,
|
||||||
|
"openpanel_url": OPENPANEL_URL,
|
||||||
|
"note": "CONNECT autologin requer OpenPanel Enterprise API — use OpenAdmin manualmente",
|
||||||
|
"bridge_configured": bridge_configured(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def health() -> dict[str, Any]:
|
||||||
|
if not bridge_configured():
|
||||||
|
return {"ok": False, "reason": "OPENPANEL_BRIDGE_TOKEN ausente", "bridge_url": BRIDGE_URL}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
res = client.get(f"{BRIDGE_URL}/api", headers=_headers())
|
||||||
|
body: dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
body = res.json()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"ok": res.status_code < 400,
|
||||||
|
"status": res.status_code,
|
||||||
|
"bridge_url": BRIDGE_URL,
|
||||||
|
"bridge": body.get("bridge"),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "reason": str(exc), "bridge_url": BRIDGE_URL}
|
||||||
|
|
||||||
|
|
||||||
|
def list_users() -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
res = client.get(f"{BRIDGE_URL}/api/users", headers=_headers())
|
||||||
|
if res.status_code >= 400:
|
||||||
|
raise OpenPanelBridgeError(f"bridge list_users HTTP {res.status_code}: {res.text[:300]}")
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(username: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=30.0) as client:
|
||||||
|
res = client.get(f"{BRIDGE_URL}/api/users/{username}", headers=_headers())
|
||||||
|
if res.status_code >= 400:
|
||||||
|
raise OpenPanelBridgeError(f"bridge get_user HTTP {res.status_code}: {res.text[:300]}")
|
||||||
|
return res.json()
|
||||||
|
|
||||||
|
|
||||||
|
def provision_user(
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
email: str,
|
||||||
|
domain: str,
|
||||||
|
plan_name: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"username": username.strip().lower(),
|
||||||
|
"password": password,
|
||||||
|
"email": email,
|
||||||
|
"domain": domain.strip().lower(),
|
||||||
|
"plan_name": plan_name or DEFAULT_PLAN,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=180.0) as client:
|
||||||
|
res = client.post(f"{BRIDGE_URL}/api/users", headers=_headers(), json=payload)
|
||||||
|
data = res.json() if res.content else {}
|
||||||
|
if res.status_code >= 400 or not data.get("success", True):
|
||||||
|
raise OpenPanelBridgeError(data.get("error") or f"bridge provision HTTP {res.status_code}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def add_domain(*, username: str, domain: str) -> dict[str, Any]:
|
||||||
|
payload = {"username": username.strip().lower(), "domain": domain.strip().lower()}
|
||||||
|
with httpx.Client(timeout=180.0) as client:
|
||||||
|
res = client.post(f"{BRIDGE_URL}/api/domains", headers=_headers(), json=payload)
|
||||||
|
data = res.json() if res.content else {}
|
||||||
|
if res.status_code >= 400 or not data.get("success", True):
|
||||||
|
raise OpenPanelBridgeError(data.get("error") or f"bridge add_domain HTTP {res.status_code}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(username: str) -> dict[str, Any]:
|
||||||
|
with httpx.Client(timeout=120.0) as client:
|
||||||
|
res = client.delete(f"{BRIDGE_URL}/api/users/{username.strip().lower()}", headers=_headers())
|
||||||
|
data = res.json() if res.content else {}
|
||||||
|
if res.status_code >= 400 or not data.get("success", True):
|
||||||
|
raise OpenPanelBridgeError(data.get("error") or f"bridge delete HTTP {res.status_code}")
|
||||||
|
return data
|
||||||
210
projects/ops-desk/api/app/vm123/openpanel_test.py
Normal file
210
projects/ops-desk/api/app/vm123/openpanel_test.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""Teste de confirmação — OpenPanel API multidomínio (Spec 028)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.vm123 import openpanel_client
|
||||||
|
|
||||||
|
TEST_PASSWORD = "LbOpenTest805353"
|
||||||
|
DEFAULT_PLAN = "ligbox-site-cms"
|
||||||
|
USER_RE = re.compile(r"^[a-z][a-z0-9]{2,15}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _step(name: str, ok: bool, detail: str = "", extra: dict | None = None) -> dict[str, Any]:
|
||||||
|
return {"name": name, "ok": ok, "detail": detail, **(extra or {})}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_accounts() -> list[tuple[str, str]]:
|
||||||
|
suffix = "".join(random.choices(string.digits, k=5))
|
||||||
|
user1 = f"apitest{suffix}"
|
||||||
|
user2 = f"apitestb{suffix}"
|
||||||
|
return [
|
||||||
|
(user1, f"apitest{suffix}.ligbox.com.br"),
|
||||||
|
(user2, f"apitestb{suffix}.ligbox.com.br"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_accounts(
|
||||||
|
accounts: list[dict[str, str]] | None,
|
||||||
|
*,
|
||||||
|
auto_names: bool = True,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
if accounts:
|
||||||
|
for row in accounts:
|
||||||
|
username = (row.get("username") or "").strip().lower()
|
||||||
|
domain = (row.get("domain") or "").strip().lower()
|
||||||
|
if not username and not domain:
|
||||||
|
continue
|
||||||
|
if not username and domain:
|
||||||
|
username = re.sub(r"[^a-z0-9]", "", domain.split(".")[0])[:15]
|
||||||
|
if not domain and username:
|
||||||
|
domain = f"{username}.ligbox.com.br" if "." not in username else username
|
||||||
|
if not USER_RE.fullmatch(username):
|
||||||
|
raise ValueError(f"username inválido: {username!r} (a-z, 3-16 chars)")
|
||||||
|
out.append((username, domain))
|
||||||
|
if not out and auto_names:
|
||||||
|
return _default_accounts()
|
||||||
|
if not out:
|
||||||
|
raise ValueError("informe pelo menos uma conta/domínio ou active auto_names")
|
||||||
|
return out[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def run_confirmation_test(
|
||||||
|
*,
|
||||||
|
triggered_by: str = "api",
|
||||||
|
accounts: list[dict[str, str]] | None = None,
|
||||||
|
password: str = TEST_PASSWORD,
|
||||||
|
cleanup: bool = True,
|
||||||
|
auto_names: bool = True,
|
||||||
|
check_reference: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Suite E2E: health → list → N contas/domínios → cleanup opcional."""
|
||||||
|
started = time.time()
|
||||||
|
steps: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
pairs = _normalize_accounts(accounts, auto_names=auto_names)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"suite": "openpanel-multidomain-api-confirm",
|
||||||
|
"message": str(exc),
|
||||||
|
"steps": [_step("validate_input", False, str(exc))],
|
||||||
|
"steps_passed": 0,
|
||||||
|
"steps_total": 1,
|
||||||
|
"triggered_by": triggered_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
health = openpanel_client.health()
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"bridge_health",
|
||||||
|
health.get("ok") is True,
|
||||||
|
f"bridge={health.get('bridge')} url={health.get('bridge_url')}",
|
||||||
|
{"response": health},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not health.get("ok"):
|
||||||
|
return _result(False, steps, pairs[0][0], triggered_by, started, pairs=pairs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
listed = openpanel_client.list_users()
|
||||||
|
users = (listed.get("users") or {}).get("data") or []
|
||||||
|
steps.append(_step("list_users", True, f"{len(users)} contas", {"count": len(users)}))
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
steps.append(_step("list_users", False, str(exc)))
|
||||||
|
return _result(False, steps, pairs[0][0], triggered_by, started, pairs=pairs)
|
||||||
|
|
||||||
|
if check_reference:
|
||||||
|
try:
|
||||||
|
ref = openpanel_client.get_user("diarissima")
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"get_reference_user",
|
||||||
|
ref.get("success") is True,
|
||||||
|
(ref.get("domains") or "")[:80],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
steps.append(_step("get_reference_user", False, str(exc)))
|
||||||
|
|
||||||
|
created: list[str] = []
|
||||||
|
for idx, (username, domain) in enumerate(pairs, start=1):
|
||||||
|
label = f"provision_{username}"
|
||||||
|
try:
|
||||||
|
prov = openpanel_client.provision_user(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
email=f"hosting@{domain}",
|
||||||
|
domain=domain,
|
||||||
|
plan_name=DEFAULT_PLAN,
|
||||||
|
)
|
||||||
|
ok = prov.get("success") is True
|
||||||
|
if ok:
|
||||||
|
created.append(username)
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
label,
|
||||||
|
ok,
|
||||||
|
prov.get("response", {}).get("message", "")[:120],
|
||||||
|
{"username": username, "domain": domain, "index": idx},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
steps.append(_step(label, False, str(exc), {"username": username, "domain": domain}))
|
||||||
|
|
||||||
|
try:
|
||||||
|
listed2 = openpanel_client.list_users()
|
||||||
|
names = {u.get("username") for u in (listed2.get("users") or {}).get("data") or []}
|
||||||
|
expected = {u for u, _ in pairs}
|
||||||
|
found = expected & names
|
||||||
|
steps.append(
|
||||||
|
_step(
|
||||||
|
"verify_multidomain_platform",
|
||||||
|
found == expected,
|
||||||
|
f"encontrados {len(found)}/{len(expected)}: {', '.join(sorted(found))}",
|
||||||
|
{"users_found": sorted(found), "users_expected": sorted(expected)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
steps.append(_step("verify_multidomain_platform", False, str(exc)))
|
||||||
|
|
||||||
|
deleted = 0
|
||||||
|
if cleanup:
|
||||||
|
for username in created:
|
||||||
|
try:
|
||||||
|
openpanel_client.delete_user(username)
|
||||||
|
deleted += 1
|
||||||
|
steps.append(_step(f"cleanup_{username}", True, "removido"))
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
steps.append(_step(f"cleanup_{username}", False, str(exc)))
|
||||||
|
else:
|
||||||
|
steps.append(_step("cleanup_skipped", True, f"mantidas: {', '.join(created)}"))
|
||||||
|
|
||||||
|
all_ok = all(s["ok"] for s in steps)
|
||||||
|
cleanup_ok = (not cleanup) or (deleted == len(created))
|
||||||
|
return _result(
|
||||||
|
all_ok and cleanup_ok,
|
||||||
|
steps,
|
||||||
|
pairs[0][0],
|
||||||
|
triggered_by,
|
||||||
|
started,
|
||||||
|
pairs=pairs,
|
||||||
|
cleanup=cleanup_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _result(
|
||||||
|
ok: bool,
|
||||||
|
steps: list[dict[str, Any]],
|
||||||
|
username: str,
|
||||||
|
triggered_by: str,
|
||||||
|
started: float,
|
||||||
|
*,
|
||||||
|
pairs: list[tuple[str, str]] | None = None,
|
||||||
|
cleanup: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
passed = sum(1 for s in steps if s["ok"])
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"suite": "openpanel-multidomain-api-confirm",
|
||||||
|
"spec": "028-openpanel-ce-ligbox-reengineering",
|
||||||
|
"triggered_by": triggered_by,
|
||||||
|
"test_user": username,
|
||||||
|
"accounts_tested": [{"username": u, "domain": d} for u, d in (pairs or [])],
|
||||||
|
"cleanup_done": cleanup,
|
||||||
|
"steps_passed": passed,
|
||||||
|
"steps_total": len(steps),
|
||||||
|
"steps": steps,
|
||||||
|
"duration_sec": round(time.time() - started, 2),
|
||||||
|
"message": (
|
||||||
|
"OpenPanel via API multidomínio Ligbox Re-engenharia — CONFIRMADO"
|
||||||
|
if ok
|
||||||
|
else "Falha em um ou mais passos — ver steps"
|
||||||
|
),
|
||||||
|
}
|
||||||
281
projects/ops-desk/api/app/vm123/permissions.py
Normal file
281
projects/ops-desk/api/app/vm123/permissions.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"""RBAC helpers for Ligbox Ops Desk — Spec 003 + 027."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Ops (Spec 003)
|
||||||
|
OPS_ROLES = frozenset({"super_admin", "ops_lead", "technician", "noc"})
|
||||||
|
|
||||||
|
# Comercial (Spec 027)
|
||||||
|
SALES_ROLES = frozenset({"sales_admin", "sales_support"})
|
||||||
|
|
||||||
|
# Negócio / plataforma (Spec 027)
|
||||||
|
BUSINESS_ROLES = frozenset(
|
||||||
|
{
|
||||||
|
"finance",
|
||||||
|
"marketing",
|
||||||
|
"seo",
|
||||||
|
"developer",
|
||||||
|
"devops",
|
||||||
|
"security_analyst",
|
||||||
|
"content_editor",
|
||||||
|
"agentic_operator",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sistema (não humanos)
|
||||||
|
SYSTEM_ROLES = frozenset({"api_service", "agent_system"})
|
||||||
|
|
||||||
|
ALL_ROLES = OPS_ROLES | SALES_ROLES | BUSINESS_ROLES | SYSTEM_ROLES
|
||||||
|
|
||||||
|
# Funções humanas (login Desk)
|
||||||
|
HUMAN_ROLES = OPS_ROLES | SALES_ROLES | BUSINESS_ROLES
|
||||||
|
|
||||||
|
# Atribuíveis no cadastro Spec 004 (exceto super_admin)
|
||||||
|
ASSIGNABLE_ROLES = HUMAN_ROLES - {"super_admin"}
|
||||||
|
|
||||||
|
# Compatibilidade com código existente
|
||||||
|
ROLES = HUMAN_ROLES
|
||||||
|
|
||||||
|
ROLE_LABELS: dict[str, str] = {
|
||||||
|
"super_admin": "Super Admin",
|
||||||
|
"ops_lead": "Chefe Ops",
|
||||||
|
"technician": "Suporte",
|
||||||
|
"noc": "NOC",
|
||||||
|
"sales_admin": "Sales Admin",
|
||||||
|
"sales_support": "Sales Support",
|
||||||
|
"finance": "Financeiro",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"seo": "SEO",
|
||||||
|
"developer": "Developer",
|
||||||
|
"devops": "DevOps",
|
||||||
|
"security_analyst": "Segurança / SOC",
|
||||||
|
"content_editor": "Conteúdo / CMS",
|
||||||
|
"agentic_operator": "Operador Agentes IA",
|
||||||
|
"api_service": "API Service",
|
||||||
|
"agent_system": "Agent System",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_role(role: str) -> bool:
|
||||||
|
return role in ALL_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def is_assignable_role(role: str) -> bool:
|
||||||
|
return role in ASSIGNABLE_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_tickets(role: str) -> bool:
|
||||||
|
return role in HUMAN_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_patch_ticket(role: str, ticket: dict, username: str) -> bool:
|
||||||
|
if role in ("super_admin", "ops_lead"):
|
||||||
|
return True
|
||||||
|
if role == "technician":
|
||||||
|
assignee = ticket.get("assigned_to")
|
||||||
|
return assignee is None or assignee == username
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_assign_ticket(role: str, assignee: str | None, username: str) -> bool:
|
||||||
|
if role in ("super_admin", "ops_lead"):
|
||||||
|
return True
|
||||||
|
if role == "technician":
|
||||||
|
return assignee in (None, username)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def can_run_audit(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_audit_overview(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"noc",
|
||||||
|
"developer",
|
||||||
|
"devops",
|
||||||
|
"security_analyst",
|
||||||
|
"agentic_operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_audit_scorecard(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"noc",
|
||||||
|
"developer",
|
||||||
|
"security_analyst",
|
||||||
|
"agentic_operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_cloudflare_dns(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"technician",
|
||||||
|
"noc",
|
||||||
|
"seo",
|
||||||
|
"devops",
|
||||||
|
"developer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_funnel(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"technician",
|
||||||
|
"noc",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"finance",
|
||||||
|
"marketing",
|
||||||
|
"seo",
|
||||||
|
"developer",
|
||||||
|
"devops",
|
||||||
|
"agentic_operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_session_timeline(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"technician",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"finance",
|
||||||
|
"marketing",
|
||||||
|
"seo",
|
||||||
|
"developer",
|
||||||
|
"devops",
|
||||||
|
"agentic_operator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_list_webhook_events(role: str, source: str | None = None) -> bool:
|
||||||
|
if role == "noc":
|
||||||
|
return source in (None, "wazuh", "vm112-security")
|
||||||
|
if role == "security_analyst":
|
||||||
|
return source in (None, "wazuh", "vm112-security", "vm112")
|
||||||
|
if role == "finance":
|
||||||
|
return source in (None, "billing", "vm112")
|
||||||
|
if role == "developer":
|
||||||
|
return source in (None, "vm112", "wazuh")
|
||||||
|
return role in HUMAN_ROLES
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_crm_leads(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"technician",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"marketing",
|
||||||
|
"seo",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_assist(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician", "sales_admin", "sales_support")
|
||||||
|
|
||||||
|
|
||||||
|
def can_assist_takeover(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_assist_handoff(role: str, username: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_users(role: str) -> bool:
|
||||||
|
return role == "super_admin"
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_vm112_domains(role: str) -> bool:
|
||||||
|
"""Admin Desk — domínios orquestrados VM112 (Spec 017)."""
|
||||||
|
return role in ("super_admin", "ops_lead", "devops")
|
||||||
|
|
||||||
|
|
||||||
|
def should_mask_sensitive(role: str) -> bool:
|
||||||
|
return role in ("noc", "sales_support")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_migration(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician", "noc", "devops")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_migration(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "ops_lead", "technician")
|
||||||
|
|
||||||
|
|
||||||
|
def can_read_billing(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"noc",
|
||||||
|
"finance",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_validate_billing(role: str) -> bool:
|
||||||
|
"""Transicionar billing_state — Spec 023 / FR-027-005 / FR-027-009."""
|
||||||
|
return role in ("super_admin", "ops_lead", "finance", "sales_admin")
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_billing(role: str) -> bool:
|
||||||
|
return can_validate_billing(role)
|
||||||
|
|
||||||
|
|
||||||
|
def can_create_foss_order(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"ops_lead",
|
||||||
|
"finance",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_foss_admin(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "finance", "sales_admin")
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_openadmin(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "devops", "sales_admin")
|
||||||
|
|
||||||
|
|
||||||
|
def can_openpanel_autologin(role: str) -> bool:
|
||||||
|
return role in (
|
||||||
|
"super_admin",
|
||||||
|
"sales_admin",
|
||||||
|
"sales_support",
|
||||||
|
"marketing",
|
||||||
|
"seo",
|
||||||
|
"content_editor",
|
||||||
|
"technician",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_openpanel_provision(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "devops", "sales_admin", "sales_support")
|
||||||
|
|
||||||
|
|
||||||
|
def can_openpanel_delete(role: str) -> bool:
|
||||||
|
return role in ("super_admin", "devops")
|
||||||
|
|
||||||
|
|
||||||
|
def roles_meta() -> dict:
|
||||||
|
"""Metadados para UI — labels e funções atribuíveis no cadastro."""
|
||||||
|
return {
|
||||||
|
"labels": ROLE_LABELS,
|
||||||
|
"assignable": sorted(ASSIGNABLE_ROLES),
|
||||||
|
"human": sorted(HUMAN_ROLES),
|
||||||
|
}
|
||||||
71
projects/ops-desk/api/app/vm123/provision.py
Normal file
71
projects/ops-desk/api/app/vm123/provision.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Provisionamento staff VM123 ao activar utilizador Desk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.vm123 import foss_client, odoo_client, openpanel_client, provision_store
|
||||||
|
from app.vm123.role_map import PROVISIONABLE_DESK_ROLES
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def provision_desk_user(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
desk_username: str,
|
||||||
|
desk_role: str,
|
||||||
|
display_name: str,
|
||||||
|
email: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Tenta FOSS + Odoo; regista resultado (mesmo parcial) em vm123_identity_map."""
|
||||||
|
if desk_role not in PROVISIONABLE_DESK_ROLES:
|
||||||
|
return {"skipped": True, "reason": f"role {desk_role} sem provisionamento automático"}
|
||||||
|
|
||||||
|
result: dict[str, Any] = {"desk_username": desk_username, "desk_role": desk_role, "steps": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["steps"]["foss"] = foss_client.create_staff(
|
||||||
|
email=email, name=display_name or email, desk_role=desk_role
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("FOSS provision failed for %s: %s", email, exc)
|
||||||
|
result["steps"]["foss"] = {"error": str(exc)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["steps"]["odoo"] = odoo_client.upsert_internal_user(
|
||||||
|
email=email, name=display_name or email, desk_role=desk_role
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Odoo provision failed for %s: %s", email, exc)
|
||||||
|
result["steps"]["odoo"] = {"error": str(exc)}
|
||||||
|
|
||||||
|
if desk_role in ("sales_admin", "sales_support", "content_editor", "seo", "marketing"):
|
||||||
|
result["steps"]["openpanel"] = {
|
||||||
|
"note": "OpenPanel staff não provisionado no MVP — autologin cliente via bridge Fase 3+",
|
||||||
|
"bridge": openpanel_client.bridge_configured(),
|
||||||
|
}
|
||||||
|
|
||||||
|
foss_id = None
|
||||||
|
foss_step = result["steps"].get("foss") or {}
|
||||||
|
if foss_step.get("foss_staff_id"):
|
||||||
|
foss_id = str(foss_step["foss_staff_id"])
|
||||||
|
|
||||||
|
odoo_uid = None
|
||||||
|
odoo_step = result["steps"].get("odoo") or {}
|
||||||
|
if odoo_step.get("odoo_uid"):
|
||||||
|
odoo_uid = int(odoo_step["odoo_uid"])
|
||||||
|
|
||||||
|
provision_store.upsert_map(
|
||||||
|
conn,
|
||||||
|
desk_username=desk_username,
|
||||||
|
desk_role=desk_role,
|
||||||
|
foss_staff_id=foss_id,
|
||||||
|
odoo_uid=odoo_uid,
|
||||||
|
provision=result,
|
||||||
|
)
|
||||||
|
result["ok"] = not any(
|
||||||
|
isinstance(step, dict) and step.get("error") for step in result["steps"].values()
|
||||||
|
)
|
||||||
|
return result
|
||||||
94
projects/ops-desk/api/app/vm123/provision_store.py
Normal file
94
projects/ops-desk/api/app/vm123/provision_store.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Persistência vm123_identity_map — Spec 027 data-model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def init_schema(conn) -> None:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS vm123_identity_map (
|
||||||
|
desk_username TEXT PRIMARY KEY,
|
||||||
|
desk_role TEXT NOT NULL,
|
||||||
|
foss_staff_id TEXT,
|
||||||
|
odoo_uid INTEGER,
|
||||||
|
openpanel_username TEXT,
|
||||||
|
provision_json TEXT,
|
||||||
|
provisioned_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_map(conn, desk_username: str) -> dict[str, Any] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM vm123_identity_map WHERE desk_username = ?",
|
||||||
|
(desk_username.strip().lower(),),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
out = dict(row)
|
||||||
|
if out.get("provision_json"):
|
||||||
|
try:
|
||||||
|
out["provision"] = json.loads(out["provision_json"])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
out["provision"] = {}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_map(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
desk_username: str,
|
||||||
|
desk_role: str,
|
||||||
|
foss_staff_id: str | None = None,
|
||||||
|
odoo_uid: int | None = None,
|
||||||
|
openpanel_username: str | None = None,
|
||||||
|
provision: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
user = desk_username.strip().lower()
|
||||||
|
now = _now()
|
||||||
|
existing = get_map(conn, user)
|
||||||
|
prov_json = json.dumps(provision or {}, ensure_ascii=False)
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE vm123_identity_map SET
|
||||||
|
desk_role = ?, foss_staff_id = COALESCE(?, foss_staff_id),
|
||||||
|
odoo_uid = COALESCE(?, odoo_uid),
|
||||||
|
openpanel_username = COALESCE(?, openpanel_username),
|
||||||
|
provision_json = ?, updated_at = ?,
|
||||||
|
provisioned_at = COALESCE(provisioned_at, ?)
|
||||||
|
WHERE desk_username = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
desk_role,
|
||||||
|
foss_staff_id,
|
||||||
|
odoo_uid,
|
||||||
|
openpanel_username,
|
||||||
|
prov_json,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
user,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO vm123_identity_map
|
||||||
|
(desk_username, desk_role, foss_staff_id, odoo_uid, openpanel_username,
|
||||||
|
provision_json, provisioned_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(user, desk_role, foss_staff_id, odoo_uid, openpanel_username, prov_json, now, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return get_map(conn, user) or {}
|
||||||
33
projects/ops-desk/api/app/vm123/role_map.py
Normal file
33
projects/ops-desk/api/app/vm123/role_map.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Mapeamento Desk role → grupos Odoo 16 (Spec 027 contrato §3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# XML IDs standard Odoo 16 — requerem apps instaladas (sale, account, …)
|
||||||
|
DESK_ROLE_ODOO_XMLIDS: dict[str, tuple[str, ...]] = {
|
||||||
|
"sales_admin": ("sales_team.group_sale_manager",),
|
||||||
|
"sales_support": ("sales_team.group_sale_salesman",),
|
||||||
|
"finance": (
|
||||||
|
"account.group_account_invoice",
|
||||||
|
"account.group_account_manager",
|
||||||
|
),
|
||||||
|
"marketing": ("sales_team.group_sale_salesman",), # CRM + leads (Odoo CRM app)
|
||||||
|
"super_admin": ("base.group_system",),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback search por nome quando módulo ainda não tem ir.model.data (dev)
|
||||||
|
DESK_ROLE_ODOO_GROUP_NAMES: dict[str, tuple[str, ...]] = {
|
||||||
|
"sales_admin": ("Sales / Manager", "User: Own Documents Only"),
|
||||||
|
"sales_support": ("Sales / User: Own Documents Only", "User: Own Documents Only"),
|
||||||
|
"finance": ("Billing", "Billing Administrator"),
|
||||||
|
}
|
||||||
|
|
||||||
|
PROVISIONABLE_DESK_ROLES = frozenset(
|
||||||
|
{"sales_admin", "sales_support", "finance", "marketing", "developer"}
|
||||||
|
)
|
||||||
|
FOSS_GROUP_BY_ROLE: dict[str, str] = {
|
||||||
|
"sales_admin": "ligbox-sales-admin",
|
||||||
|
"sales_support": "ligbox-sales-support",
|
||||||
|
"finance": "ligbox-finance-admin",
|
||||||
|
"marketing": "ligbox-marketing",
|
||||||
|
"developer": "ligbox-dev-api",
|
||||||
|
}
|
||||||
290
projects/ops-desk/api/app/vm123/routes.py
Normal file
290
projects/ops-desk/api/app/vm123/routes.py
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
"""Rotas VM123 — Spec 027 Fase 3."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app import auth
|
||||||
|
from app.permissions import (
|
||||||
|
can_access_foss_admin,
|
||||||
|
can_create_foss_order,
|
||||||
|
can_manage_users,
|
||||||
|
can_read_billing,
|
||||||
|
)
|
||||||
|
from app.vm123.permissions import (
|
||||||
|
can_openpanel_autologin,
|
||||||
|
can_openpanel_delete,
|
||||||
|
can_openpanel_provision,
|
||||||
|
)
|
||||||
|
from app.platform_role_catalog import catalog_export
|
||||||
|
from app.vm123 import foss_client, odoo_client, openpanel_client, openpanel_test, provision, provision_store
|
||||||
|
from app.vm123.role_map import PROVISIONABLE_DESK_ROLES
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/vm123", tags=["vm123"])
|
||||||
|
|
||||||
|
|
||||||
|
class FossOrderBody(BaseModel):
|
||||||
|
client_id: int | None = None
|
||||||
|
domain: str | None = None
|
||||||
|
product_id: int | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisionUserBody(BaseModel):
|
||||||
|
desk_username: str = Field(min_length=3)
|
||||||
|
desk_role: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPanelProvisionBody(BaseModel):
|
||||||
|
username: str | None = None
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
email: str = Field(min_length=5)
|
||||||
|
domain: str = Field(min_length=3)
|
||||||
|
plan_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPanelTestAccount(BaseModel):
|
||||||
|
username: str = ""
|
||||||
|
domain: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenPanelTestConfirmBody(BaseModel):
|
||||||
|
accounts: list[OpenPanelTestAccount] = Field(default_factory=list)
|
||||||
|
password: str = Field(default="LbOpenTest805353", min_length=8)
|
||||||
|
cleanup: bool = True
|
||||||
|
auto_names: bool = True
|
||||||
|
check_reference: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/platform/catalog")
|
||||||
|
def platform_role_catalog(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
"""Catálogo mestre função → serviços (padrão Odoo res.groups na plataforma DevOps)."""
|
||||||
|
return catalog_export()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def vm123_health(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if user.role not in ("super_admin", "devops", "developer"):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
out: dict = {"odoo": {"configured": odoo_client._configured()}}
|
||||||
|
try:
|
||||||
|
out["odoo"]["role_model_sales_admin"] = odoo_client.list_role_model("sales_admin")
|
||||||
|
except Exception as exc:
|
||||||
|
out["odoo"]["error"] = str(exc)
|
||||||
|
out["foss"] = {"configured": foss_client._configured()}
|
||||||
|
out["openpanel"] = openpanel_client.health()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/odoo/role-model/{role}")
|
||||||
|
def odoo_role_model(role: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_manage_users(user.role) and user.role not in ("devops", "developer"):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
return odoo_client.list_role_model(role)
|
||||||
|
except odoo_client.OdooConfigError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/odoo/partner")
|
||||||
|
def odoo_partner(email: str = Query(..., min_length=3), user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
partner = odoo_client.find_partner_by_email(email)
|
||||||
|
except odoo_client.OdooConfigError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
if not partner:
|
||||||
|
raise HTTPException(404, "parceiro não encontrado")
|
||||||
|
return {
|
||||||
|
"partner": partner,
|
||||||
|
"login_url": odoo_client.ODOO_PUBLIC_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/foss/client/{domain}")
|
||||||
|
def foss_client_by_domain(domain: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
client_row = foss_client.find_client_by_domain(domain)
|
||||||
|
except foss_client.FossConfigError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
if not client_row:
|
||||||
|
raise HTTPException(404, "cliente FOSS não encontrado")
|
||||||
|
return {
|
||||||
|
"client": client_row,
|
||||||
|
"admin_url": foss_client.FOSS_PUBLIC_ADMIN,
|
||||||
|
"can_order": can_create_foss_order(user.role),
|
||||||
|
"can_admin": can_access_foss_admin(user.role),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/foss/order")
|
||||||
|
def foss_create_order(body: FossOrderBody, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_create_foss_order(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
if not body.client_id and not body.domain:
|
||||||
|
raise HTTPException(400, "informe client_id ou domain")
|
||||||
|
# MVP: delegar criação real à UI FOSS até mapear product_id
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"message": "Pedido registado — criação FOSS via Admin até product_id estar mapeado",
|
||||||
|
"payload": body.model_dump(),
|
||||||
|
"foss_admin": foss_client.FOSS_PUBLIC_ADMIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openpanel/autologin/{username}")
|
||||||
|
def openpanel_autologin(username: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_openpanel_autologin(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
return openpanel_client.autologin_payload(username)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/openpanel/users")
|
||||||
|
def openpanel_list_users(user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
return openpanel_client.list_users()
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/openpanel/users/{username}")
|
||||||
|
def openpanel_get_user(username: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
return openpanel_client.get_user(username)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openpanel/provision")
|
||||||
|
def openpanel_provision(body: OpenPanelProvisionBody, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_openpanel_provision(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
return openpanel_client.provision_user(
|
||||||
|
username=body.username or "",
|
||||||
|
password=body.password,
|
||||||
|
email=body.email,
|
||||||
|
domain=body.domain,
|
||||||
|
plan_name=body.plan_name,
|
||||||
|
)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
raise HTTPException(502, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/openpanel/users/{username}")
|
||||||
|
def openpanel_delete_user(username: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_openpanel_delete(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
try:
|
||||||
|
return openpanel_client.delete_user(username)
|
||||||
|
except openpanel_client.OpenPanelBridgeError as exc:
|
||||||
|
raise HTTPException(502, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openpanel/test-confirm")
|
||||||
|
def openpanel_test_confirm(
|
||||||
|
body: OpenPanelTestConfirmBody | None = None,
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
"""Suite de confirmação Spec 028 — multidomínio via Desk API → bridge → opencli."""
|
||||||
|
if user.role not in ("super_admin", "devops", "developer"):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
if not openpanel_client.bridge_configured():
|
||||||
|
raise HTTPException(503, "OPENPANEL_BRIDGE_TOKEN não configurado")
|
||||||
|
payload = body or OpenPanelTestConfirmBody()
|
||||||
|
accounts = [a.model_dump() for a in payload.accounts if a.username or a.domain]
|
||||||
|
return openpanel_test.run_confirmation_test(
|
||||||
|
triggered_by=user.username,
|
||||||
|
accounts=accounts or None,
|
||||||
|
password=payload.password,
|
||||||
|
cleanup=payload.cleanup,
|
||||||
|
auto_names=payload.auto_names,
|
||||||
|
check_reference=payload.check_reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/identity/{desk_username}")
|
||||||
|
def get_identity_map(desk_username: str, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
with auth.db() as conn:
|
||||||
|
row = provision_store.get_map(conn, desk_username)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "sem registo VM123")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/provision/user")
|
||||||
|
def provision_user(body: ProvisionUserBody, user: auth.DeskUser = Depends(auth.get_current_user)):
|
||||||
|
if not can_manage_users(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
with auth.db() as conn:
|
||||||
|
urow = conn.execute(
|
||||||
|
"SELECT username, role, display_name, email FROM desk_users WHERE username = ?",
|
||||||
|
(body.desk_username.strip().lower(),),
|
||||||
|
).fetchone()
|
||||||
|
if not urow:
|
||||||
|
raise HTTPException(404, "utilizador Desk não encontrado")
|
||||||
|
role = body.desk_role or urow["role"]
|
||||||
|
if role not in PROVISIONABLE_DESK_ROLES:
|
||||||
|
raise HTTPException(400, f"role {role} não provisionável")
|
||||||
|
email = urow["email"] or urow["username"]
|
||||||
|
result = provision.provision_desk_user(
|
||||||
|
conn,
|
||||||
|
desk_username=urow["username"],
|
||||||
|
desk_role=role,
|
||||||
|
display_name=urow["display_name"] or email,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/links/client")
|
||||||
|
def client_deep_links(
|
||||||
|
domain: str = Query(..., min_length=3),
|
||||||
|
email: str = "",
|
||||||
|
user: auth.DeskUser = Depends(auth.get_current_user),
|
||||||
|
):
|
||||||
|
"""Deep-links drawer «Conta do cliente» — Spec 023 + 027."""
|
||||||
|
if not can_read_billing(user.role):
|
||||||
|
raise HTTPException(403, "permissão insuficiente")
|
||||||
|
links = {
|
||||||
|
"domain": domain.strip().lower(),
|
||||||
|
"foss": {"url": foss_client.FOSS_PUBLIC_ADMIN, "label": "FOSSBilling Admin"},
|
||||||
|
"odoo": {"url": odoo_client.ODOO_PUBLIC_URL, "label": "Odoo ligbox"},
|
||||||
|
"openpanel": {"url": openpanel_client.OPENADMIN_URL, "label": "OpenAdmin"},
|
||||||
|
}
|
||||||
|
out: dict = {"links": links, "role": user.role}
|
||||||
|
if foss_client._configured():
|
||||||
|
try:
|
||||||
|
fc = foss_client.find_client_by_domain(domain)
|
||||||
|
if fc:
|
||||||
|
out["foss"]["client_id"] = fc.get("id")
|
||||||
|
out["foss"]["client_email"] = fc.get("email")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
bill_email = (email or "").strip()
|
||||||
|
if bill_email and odoo_client._configured():
|
||||||
|
try:
|
||||||
|
partner = odoo_client.find_partner_by_email(bill_email)
|
||||||
|
if partner:
|
||||||
|
out["odoo"]["partner_id"] = partner.get("id")
|
||||||
|
out["odoo"]["partner_name"] = partner.get("name")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out["permissions"] = {
|
||||||
|
"can_order": can_create_foss_order(user.role),
|
||||||
|
"can_foss_admin": can_access_foss_admin(user.role),
|
||||||
|
"can_openpanel_autologin": can_openpanel_autologin(user.role),
|
||||||
|
"can_openpanel_provision": can_openpanel_provision(user.role),
|
||||||
|
}
|
||||||
|
links["openpanel"]["bridge_ok"] = openpanel_client.health().get("ok", False)
|
||||||
|
return out
|
||||||
|
|
@ -204,11 +204,11 @@ function setView(name) {
|
||||||
const titles = {
|
const titles = {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
overview: 'Audit Overview',
|
overview: 'Audit Overview',
|
||||||
'overview-home': 'Serviços',
|
'overview-home': 'Serviços IaaS',
|
||||||
tickets: 'Tickets',
|
tickets: 'Tickets',
|
||||||
events: 'Eventos webhook',
|
events: 'Eventos webhook',
|
||||||
tenants: 'Tenants',
|
tenants: 'Tenants',
|
||||||
infra: 'Infraestrutura',
|
infra: 'INFRA CODE',
|
||||||
infra2: 'SOC — Infra 2',
|
infra2: 'SOC — Infra 2',
|
||||||
messages: 'Mensagens — pedidos de cadastro',
|
messages: 'Mensagens — pedidos de cadastro',
|
||||||
admin: 'Administradores',
|
admin: 'Administradores',
|
||||||
|
|
@ -219,11 +219,11 @@ function setView(name) {
|
||||||
const subtitles = {
|
const subtitles = {
|
||||||
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
|
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
|
||||||
'overview-home': 'Desk VM122 · Orquestração MOSP',
|
'overview-home': 'Orquestração MOSP · Infra as Code',
|
||||||
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
infra: 'VM112, VM104 e integrações — visão técnica',
|
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
|
||||||
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
|
||||||
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
|
|
@ -3825,122 +3825,282 @@ function infraKvHtml(items) {
|
||||||
).join('')}</dl>`;
|
).join('')}</dl>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROC_CARD_ICONS = {
|
||||||
|
soc: '📡',
|
||||||
|
openpanel: '🎛️',
|
||||||
|
purge: '🔐',
|
||||||
|
vm112: '🌐',
|
||||||
|
wazuh: '🛡️',
|
||||||
|
integrations: '🔗',
|
||||||
|
};
|
||||||
|
|
||||||
|
function procCardHtml(opts) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
accent = 'teal',
|
||||||
|
title,
|
||||||
|
spec,
|
||||||
|
desc,
|
||||||
|
statusLabel,
|
||||||
|
statusCls = 'review',
|
||||||
|
actions = [],
|
||||||
|
} = opts;
|
||||||
|
const acts = actions.map((a) =>
|
||||||
|
`<button type="button" class="btn ${a.primary ? 'secondary' : 'btn-ghost'} btn-sm" data-stack-action="${esc(a.action || 'detail')}" data-stack-id="${esc(id)}">${esc(a.label)}</button>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<article class="proc-card proc-card--${accent}" data-stack-id="${esc(id)}">
|
||||||
|
<span class="badge proc-card-badge ${statusCls}">${esc(statusLabel)}</span>
|
||||||
|
<header class="proc-card-head">
|
||||||
|
<span class="proc-card-icon" aria-hidden="true">${icon}</span>
|
||||||
|
<span class="proc-card-spec">${esc(spec)}</span>
|
||||||
|
</header>
|
||||||
|
<h3 class="proc-card-title">${esc(title)}</h3>
|
||||||
|
<p class="proc-card-desc">${desc}</p>
|
||||||
|
<footer class="proc-card-foot">${acts}</footer>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInfraProcessModal() {
|
||||||
|
const modal = document.getElementById('infra-process-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInfraProcessModal(title, sub, bodyHtml) {
|
||||||
|
const modal = document.getElementById('infra-process-modal');
|
||||||
|
const titleEl = document.getElementById('infra-process-modal-title');
|
||||||
|
const subEl = document.getElementById('infra-process-modal-sub');
|
||||||
|
const body = document.getElementById('infra-process-modal-body');
|
||||||
|
if (!modal || !body) return;
|
||||||
|
if (titleEl) titleEl.textContent = title;
|
||||||
|
if (subEl) subEl.textContent = sub || '';
|
||||||
|
body.innerHTML = bodyHtml;
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInfraProcessModal() {
|
||||||
|
document.querySelectorAll('[data-close-infra-process-modal]').forEach((el) => {
|
||||||
|
el.addEventListener('click', closeInfraProcessModal);
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeInfraProcessModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStackService(id) {
|
||||||
|
const stack = state.infraSnapshot?.stack;
|
||||||
|
if (!stack) return null;
|
||||||
|
for (const vm of stack.vms || []) {
|
||||||
|
for (const svc of vm.services || []) {
|
||||||
|
if (svc.id === id) return { vm, svc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackServiceActions(svcId) {
|
||||||
|
if (svcId === 'vm122-purge-auth') {
|
||||||
|
return [{ label: 'Gerir códigos', action: 'purge-manage', primary: true }];
|
||||||
|
}
|
||||||
|
const actions = [{ label: 'Detalhes', action: 'detail', primary: true }];
|
||||||
|
if (svcId === 'vm122-webhook-soc') actions.push({ label: 'Testar', action: 'test-webhook', primary: false });
|
||||||
|
if (svcId === 'vm123-openpanel-bridge') actions.push({ label: 'Testar', action: 'test-openpanel', primary: false });
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackServiceStatusCls(svc) {
|
||||||
|
if (svc.ok) return 'ok';
|
||||||
|
if (svc.status === 'check') return 'review';
|
||||||
|
return 'escalated';
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindStackCardActions(root) {
|
||||||
|
root?.querySelectorAll('[data-stack-action]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const action = btn.dataset.stackAction;
|
||||||
|
const id = btn.dataset.stackId;
|
||||||
|
if (action === 'detail') openStackServiceDetail(id);
|
||||||
|
else if (action === 'test-webhook') runWebhookIntegrationTest('infra');
|
||||||
|
else if (action === 'test-openpanel') runOpenPanelApiTest();
|
||||||
|
else if (action === 'purge-manage') openInfraProcessDetail('purge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStackServiceDetail(svcId) {
|
||||||
|
const hit = findStackService(svcId);
|
||||||
|
if (!hit) {
|
||||||
|
if (svcId === 'integrations-json') openInfraProcessDetail('integrations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { vm, svc } = hit;
|
||||||
|
const specLabel = svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : svc.kind || 'stack';
|
||||||
|
openInfraProcessModal(
|
||||||
|
svc.title,
|
||||||
|
`${vm.vm_label} · ${specLabel}`,
|
||||||
|
`${infraKvHtml([
|
||||||
|
['VM', `${vm.vm} · ${esc(vm.ip)}`],
|
||||||
|
['Tipo', esc(svc.kind || '—')],
|
||||||
|
['URL', `<code>${esc(svc.url || '—')}</code>`],
|
||||||
|
['Status', esc(svc.status || '—')],
|
||||||
|
['HTTP', svc.http_status != null ? String(svc.http_status) : '—'],
|
||||||
|
['Detalhe', esc(svc.detail || '—')],
|
||||||
|
])}
|
||||||
|
<div class="infra-actions">
|
||||||
|
${svc.url && svc.url.startsWith('http') ? `<a class="btn btn-ghost btn-sm" href="${esc(svc.url)}" target="_blank" rel="noopener">Abrir URL</a>` : ''}
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInfraProcessDetail(procId) {
|
||||||
|
const snap = state.infraSnapshot || {};
|
||||||
|
const health = snap.health || {};
|
||||||
|
const integrations = snap.integrations;
|
||||||
|
const onboard = health.vm112_onboard || {};
|
||||||
|
const last = onboard.last_webhook;
|
||||||
|
const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—';
|
||||||
|
const vmOk = onboard.vm112_api?.reachable;
|
||||||
|
const alerts = (health.alerts || []).map((a) =>
|
||||||
|
`<li class="badge ${a.level === 'critical' ? 'escalated' : 'assisting'}">${esc(a.message)}</li>`
|
||||||
|
).join('') || '<li class="muted">Nenhum alerta activo</li>';
|
||||||
|
|
||||||
|
if (procId === 'soc' || procId === 'vm122-webhook-soc') {
|
||||||
|
openInfraProcessModal(
|
||||||
|
'SOC — Integração VM112',
|
||||||
|
'Webhook onboard · alertas de gap',
|
||||||
|
`${infraKvHtml([
|
||||||
|
['Último evento', last ? esc(last.event) : '—'],
|
||||||
|
['Domínio', last?.domain ? esc(last.domain) : '—'],
|
||||||
|
['Há quanto tempo', gap],
|
||||||
|
['VM112 API', vmOk ? 'OK' : esc(onboard.vm112_api?.error || 'offline')],
|
||||||
|
['Status integração', esc(health.status || '—')],
|
||||||
|
])}
|
||||||
|
<ul class="infra-alert-list">${alerts}</ul>
|
||||||
|
<div class="infra-actions">
|
||||||
|
<button type="button" class="btn secondary btn-sm" id="btn-test-webhook-modal">Testar webhook</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-refresh-health-modal">Atualizar</button>
|
||||||
|
</div>
|
||||||
|
<p class="infra-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>`
|
||||||
|
);
|
||||||
|
document.getElementById('btn-test-webhook-modal')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
|
||||||
|
document.getElementById('btn-refresh-health-modal')?.addEventListener('click', () => {
|
||||||
|
closeInfraProcessModal();
|
||||||
|
renderInfra();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (procId === 'openpanel' || procId === 'vm123-openpanel-bridge') {
|
||||||
|
const vm123Health = snap.vm123Health;
|
||||||
|
const op = vm123Health?.openpanel || findStackService('vm123-openpanel-bridge')?.svc || {};
|
||||||
|
const opOk = Boolean(op.ok);
|
||||||
|
const bridgeOk = Boolean(op.bridge);
|
||||||
|
openInfraProcessModal(
|
||||||
|
'OpenPanel API — Re-engenharia Ligbox',
|
||||||
|
'Spec 028 · VM123 bridge :18087',
|
||||||
|
`<p class="infra-hint">Multidomínio · conta temporária com cleanup automático.</p>
|
||||||
|
${infraKvHtml([
|
||||||
|
['OpenPanel', opOk ? 'OK' : esc(op.error || op.detail || 'offline')],
|
||||||
|
['Bridge API', bridgeOk ? 'OK' : 'offline'],
|
||||||
|
['Bridge URL', esc(op.bridge_url || op.url || '—')],
|
||||||
|
])}
|
||||||
|
<div class="infra-actions">
|
||||||
|
<button type="button" class="btn secondary btn-sm" id="btn-test-openpanel-modal">Testar multidomínio</button>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="https://openpanel.ligbox.com.br" target="_blank" rel="noopener">OpenPanel UI</a>
|
||||||
|
</div>
|
||||||
|
<p class="infra-hint">Suite <code>openpanel-multidomain-api-confirm</code></p>`
|
||||||
|
);
|
||||||
|
document.getElementById('btn-test-openpanel-modal')?.addEventListener('click', () => runOpenPanelApiTest());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (procId === 'purge' || procId === 'vm122-purge-auth') {
|
||||||
|
openInfraProcessModal(
|
||||||
|
'Códigos purge — autorização extra',
|
||||||
|
'Spec 032 · domínios protegidos',
|
||||||
|
'<div id="purge-auth-modal-panel"><p class="loading">A carregar…</p></div>'
|
||||||
|
);
|
||||||
|
renderPurgeAuthPanel(document.getElementById('purge-auth-modal-panel'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (procId === 'integrations') {
|
||||||
|
openInfraProcessModal(
|
||||||
|
'Integrações activas',
|
||||||
|
'Snapshot JSON · Desk API',
|
||||||
|
`<div class="infra-json-panel"><pre class="raw">${esc(JSON.stringify(integrations || {}, null, 2))}</pre></div>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderInfra() {
|
async function renderInfra() {
|
||||||
const el = document.getElementById('infra-content');
|
const el = document.getElementById('infra-content');
|
||||||
el.innerHTML = '<p class="loading">Verificando…</p>';
|
el.innerHTML = '<p class="loading">Verificando stack…</p>';
|
||||||
try {
|
try {
|
||||||
const [vm112, wazuh, integrations, health] = await Promise.all([
|
const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([
|
||||||
api('/v1/infra/vm112/status'),
|
api('/v1/infra/stack/status'),
|
||||||
api('/v1/infra/wazuh/status'),
|
api('/v1/integrations').catch(() => null),
|
||||||
api('/v1/integrations'),
|
api('/v1/integrations/health').catch(() => ({})),
|
||||||
api('/v1/integrations/health'),
|
api('/v1/vm123/health').catch(() => null),
|
||||||
|
api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })),
|
||||||
]);
|
]);
|
||||||
const onboard = health.vm112_onboard || {};
|
state.infraSnapshot = { stack, integrations, health, vm123Health, purgeMeta };
|
||||||
const last = onboard.last_webhook;
|
const summary = stack.summary || {};
|
||||||
const gap = onboard.gap_minutes != null ? `${Math.round(onboard.gap_minutes)} min` : '—';
|
const okCls = summary.ok === summary.total ? 'ok' : summary.ok > 0 ? 'assisting' : 'escalated';
|
||||||
const vmOk = onboard.vm112_api?.reachable;
|
let sections = (stack.vms || []).map((vm) => {
|
||||||
const wazuhOk = wazuh.http_status === 200;
|
const cards = (vm.services || []).map((svc) =>
|
||||||
const statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting';
|
procCardHtml({
|
||||||
const heroHealthDot = health.status === 'ok' ? '' : health.status === 'critical' ? 'infra-hero-dot--bad' : 'infra-hero-dot--warn';
|
id: svc.id,
|
||||||
const alerts = (health.alerts || []).map((a) =>
|
icon: svc.icon || '⚙️',
|
||||||
`<li class="badge ${a.level === 'critical' ? 'escalated' : 'assisting'}">${esc(a.message)}</li>`
|
accent: svc.accent || 'teal',
|
||||||
).join('') || '<li class="muted">Nenhum alerta activo</li>';
|
title: svc.title,
|
||||||
|
spec: svc.spec && svc.spec !== '—' ? `Spec ${svc.spec}` : String(svc.kind || 'stack').toUpperCase(),
|
||||||
|
desc: esc(svc.detail || svc.url || ''),
|
||||||
|
statusLabel: svc.status || (svc.ok ? 'online' : 'down'),
|
||||||
|
statusCls: stackServiceStatusCls(svc),
|
||||||
|
actions: stackServiceActions(svc.id),
|
||||||
|
})
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<section class="infra-vm-section">
|
||||||
|
<h3 class="infra-vm-head">${esc(vm.vm_label)} <span class="ticket-meta">${esc(vm.ip)}</span></h3>
|
||||||
|
<div class="proc-grid">${cards}</div>
|
||||||
|
</section>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const integrationsCard = integrations
|
||||||
|
? procCardHtml({
|
||||||
|
id: 'integrations-json',
|
||||||
|
icon: '🔗',
|
||||||
|
accent: 'violet',
|
||||||
|
title: 'Integrações Desk',
|
||||||
|
spec: 'JSON',
|
||||||
|
desc: 'Registry onboard + Wazuh · snapshot API',
|
||||||
|
statusLabel: 'activas',
|
||||||
|
statusCls: 'open',
|
||||||
|
actions: [{ label: 'Ver JSON', action: 'detail', primary: true }],
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="infra-page">
|
<div class="infra-page">
|
||||||
<div class="infra-hero">
|
<div class="infra-stack-summary">
|
||||||
<div class="infra-hero-chip">
|
<span class="badge ${okCls}">${summary.ok ?? 0}/${summary.total ?? 0} online</span>
|
||||||
<span class="infra-hero-dot ${heroHealthDot}" aria-hidden="true"></span>
|
<span class="infra-hint">Stack Ligbox · VMs 112 · 114 · 122 · 123 · 130 · Infra as Code · ${fmtDate(stack.generated_at)}</span>
|
||||||
<div class="infra-hero-body">
|
<button type="button" class="btn btn-ghost btn-sm" id="btn-infra-refresh-stack">Atualizar stack</button>
|
||||||
<strong>SOC integração</strong>
|
|
||||||
<span>Webhook VM112 · gap ${gap}</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge ${statusCls}">${esc(health.status || '—')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="infra-hero-chip">
|
|
||||||
<span class="infra-hero-dot ${vmOk ? '' : 'infra-hero-dot--warn'}" aria-hidden="true"></span>
|
|
||||||
<div class="infra-hero-body">
|
|
||||||
<strong>VM112 Portal</strong>
|
|
||||||
<span>${esc(vm112.vm112?.service || vm112.error || '—')}</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge ${vmOk ? 'ok' : 'review'}">${vmOk ? 'online' : 'check'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="infra-hero-chip">
|
|
||||||
<span class="infra-hero-dot ${wazuhOk ? '' : 'infra-hero-dot--warn'}" aria-hidden="true"></span>
|
|
||||||
<div class="infra-hero-body">
|
|
||||||
<strong>VM104 Wazuh</strong>
|
|
||||||
<span>API HTTP ${wazuh.http_status ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge ${wazuhOk ? 'ok' : 'review'}">${wazuhOk ? 'online' : 'check'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="infra-grid">
|
|
||||||
<article class="ws-panel infra-panel infra-panel--wide">
|
|
||||||
<div class="ws-panel-head ws-panel-head--teal">SOC — Integração VM112</div>
|
|
||||||
<div class="ws-panel-body">
|
|
||||||
${infraKvHtml([
|
|
||||||
['Último evento', last ? esc(last.event) : '—'],
|
|
||||||
['Domínio', last?.domain ? esc(last.domain) : '—'],
|
|
||||||
['Há quanto tempo', gap],
|
|
||||||
['VM112 API', vmOk ? 'OK' : esc(onboard.vm112_api?.error || 'offline')],
|
|
||||||
])}
|
|
||||||
<ul class="infra-alert-list">${alerts}</ul>
|
|
||||||
<div class="infra-actions">
|
|
||||||
<button type="button" class="btn secondary btn-sm" id="btn-test-webhook">Testar webhook</button>
|
|
||||||
<button type="button" class="btn secondary btn-sm" id="btn-refresh-health">Atualizar</button>
|
|
||||||
</div>
|
|
||||||
<p class="infra-hint">Alerta se gap > ${health.webhook_gap_alert_minutes || 15} min sem eventos VM112.</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="ws-panel infra-panel">
|
|
||||||
<div class="ws-panel-head ws-panel-head--rose">Códigos purge · Spec 032</div>
|
|
||||||
<div class="ws-panel-body" id="purge-auth-infra-panel"><p class="loading">A carregar…</p></div>
|
|
||||||
</article>
|
|
||||||
<article class="ws-panel infra-panel">
|
|
||||||
<div class="ws-panel-head ws-panel-head--orange">OpenPanel API</div>
|
|
||||||
<div class="ws-panel-body">
|
|
||||||
<p class="infra-hint">Spec 028 · VM123 bridge :18087 · multidomínio · conta temporária com cleanup.</p>
|
|
||||||
<div class="infra-actions">
|
|
||||||
<button type="button" class="btn secondary btn-sm" id="btn-test-openpanel-api">Testar multidomínio</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="ws-panel infra-panel">
|
|
||||||
<div class="ws-panel-head ws-panel-head--teal">VM112 — Onboard</div>
|
|
||||||
<div class="ws-panel-body">
|
|
||||||
${infraKvHtml([
|
|
||||||
['HTTP', String(vm112.http_status ?? '—')],
|
|
||||||
['Service', esc(vm112.vm112?.service || vm112.error || '—')],
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="ws-panel infra-panel">
|
|
||||||
<div class="ws-panel-head ws-panel-head--slate">VM104 — Wazuh SOC</div>
|
|
||||||
<div class="ws-panel-body">
|
|
||||||
${infraKvHtml([
|
|
||||||
['API HTTP', String(wazuh.http_status ?? '—')],
|
|
||||||
['Integração', 'webhook level ≥ 10 → VM122'],
|
|
||||||
])}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="ws-panel infra-panel infra-panel--wide">
|
|
||||||
<div class="ws-panel-head ws-panel-head--violet">Integrações activas</div>
|
|
||||||
<div class="ws-panel-body infra-json-panel" style="padding:0">
|
|
||||||
<pre class="raw">${esc(JSON.stringify(integrations, null, 2))}</pre>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
${sections}
|
||||||
|
${integrationsCard ? `<section class="infra-vm-section"><h3 class="infra-vm-head">Integrações · Desk API</h3><div class="proc-grid">${integrationsCard}</div></section>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
|
document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra());
|
||||||
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
|
bindStackCardActions(el);
|
||||||
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
|
|
||||||
await renderPurgeAuthInfraPanel();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPurgeAuthInfraPanel() {
|
async function renderPurgeAuthPanel(panel) {
|
||||||
const panel = document.getElementById('purge-auth-infra-panel');
|
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
try {
|
try {
|
||||||
const meta = await api('/v1/infra/purge-auth-domains');
|
const meta = await api('/v1/infra/purge-auth-domains');
|
||||||
|
|
@ -4038,6 +4198,10 @@ async function renderPurgeAuthInfraPanel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderPurgeAuthInfraPanel() {
|
||||||
|
await renderPurgeAuthPanel(document.getElementById('purge-auth-infra-panel'));
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh(options = {}) {
|
async function refresh(options = {}) {
|
||||||
const { poll = false } = options;
|
const { poll = false } = options;
|
||||||
await loadHealth();
|
await loadHealth();
|
||||||
|
|
@ -4121,6 +4285,7 @@ document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
||||||
applyRoleNav();
|
applyRoleNav();
|
||||||
DeskModules.applyVisibility();
|
DeskModules.applyVisibility();
|
||||||
bindOverviewModal();
|
bindOverviewModal();
|
||||||
|
bindInfraProcessModal();
|
||||||
bindTeamDrawerClose();
|
bindTeamDrawerClose();
|
||||||
bindSocTestModal();
|
bindSocTestModal();
|
||||||
setView('dashboard');
|
setView('dashboard');
|
||||||
|
|
|
||||||
|
|
@ -3901,6 +3901,26 @@ button.health-card {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.infra-stack-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: linear-gradient(135deg, #f0fdfa 0%, #fff 70%);
|
||||||
|
}
|
||||||
|
.infra-vm-section {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.infra-vm-head {
|
||||||
|
margin: 0 0 0.65rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
.infra-hero {
|
.infra-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -3918,10 +3938,14 @@ button.health-card {
|
||||||
background: linear-gradient(135deg, #f0fdfa 0%, #fff 55%);
|
background: linear-gradient(135deg, #f0fdfa 0%, #fff 55%);
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
.infra-hero-chip--alert {
|
.infra-hero-chip--openpanel {
|
||||||
background: linear-gradient(135deg, #fff7ed 0%, #fff 55%);
|
background: linear-gradient(135deg, #fff7ed 0%, #fff 55%);
|
||||||
border-color: #fed7aa;
|
border-color: #fed7aa;
|
||||||
}
|
}
|
||||||
|
.infra-panel--featured {
|
||||||
|
border-color: #fdba74;
|
||||||
|
box-shadow: 0 4px 14px rgba(234, 88, 12, 0.12);
|
||||||
|
}
|
||||||
.infra-hero-dot {
|
.infra-hero-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
|
|
@ -4107,6 +4131,102 @@ button.health-card {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Process cards — grid uniforme (Spec 033 § proc-card) */
|
||||||
|
.proc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.proc-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 168px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
.proc-card--teal { border-top: 3px solid #14b8a6; }
|
||||||
|
.proc-card--orange { border-top: 3px solid #f97316; }
|
||||||
|
.proc-card--rose { border-top: 3px solid #f43f5e; }
|
||||||
|
.proc-card--slate { border-top: 3px solid #64748b; }
|
||||||
|
.proc-card--violet { border-top: 3px solid #8b5cf6; }
|
||||||
|
.proc-card--aqua { border-top: 3px solid #06b6d4; }
|
||||||
|
.proc-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
.proc-card-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.proc-card--teal .proc-card-icon { background: #ccfbf1; }
|
||||||
|
.proc-card--orange .proc-card-icon { background: #ffedd5; }
|
||||||
|
.proc-card--rose .proc-card-icon { background: #ffe4e6; }
|
||||||
|
.proc-card--slate .proc-card-icon { background: #f1f5f9; }
|
||||||
|
.proc-card--violet .proc-card-icon { background: #ede9fe; }
|
||||||
|
.proc-card--aqua .proc-card-icon { background: #cffafe; }
|
||||||
|
.proc-card-spec {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 42%;
|
||||||
|
}
|
||||||
|
.proc-card-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
.proc-card-title {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding-right: 3.5rem;
|
||||||
|
}
|
||||||
|
.proc-card-desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.45;
|
||||||
|
flex: 1;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.proc-card-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.proc-card-foot .btn { flex: 1 1 auto; min-width: 0; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.proc-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Spec 021 — Acesso utilizador (separado do VM112 Onboard) */
|
/* Spec 021 — Acesso utilizador (separado do VM112 Onboard) */
|
||||||
.ws-access-zone {
|
.ws-access-zone {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="overview-home" data-module="overview-home" id="nav-overview-home" class="nav-item nav-item-overview-home">
|
<button type="button" data-view="overview-home" data-module="overview-home" id="nav-overview-home" class="nav-item nav-item-overview-home">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-overview-home"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-overview-home"/></svg></span>
|
||||||
<span class="nav-label">Serviços</span>
|
<span class="nav-label">Serviços IaaS</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="tickets" data-module="core" class="nav-item nav-item-tickets">
|
<button type="button" data-view="tickets" data-module="core" class="nav-item nav-item-tickets">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-tickets"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-tickets"/></svg></span>
|
||||||
|
|
@ -218,7 +218,7 @@
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="infra" data-module="infra" class="nav-item nav-item-infra">
|
<button type="button" data-view="infra" data-module="infra" class="nav-item nav-item-infra">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
|
||||||
<span class="nav-label">Infra</span>
|
<span class="nav-label">INFRA CODE</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="infra2" data-module="infra2-soc" class="nav-item nav-item-infra2">
|
<button type="button" data-view="infra2" data-module="infra2-soc" class="nav-item nav-item-infra2">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
||||||
|
|
@ -413,14 +413,27 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="infra-process-modal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" data-close-infra-process-modal></div>
|
||||||
|
<div class="modal-panel modal-panel-lg" role="dialog" aria-modal="true" aria-labelledby="infra-process-modal-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="infra-process-modal-title">Processo</h3>
|
||||||
|
<p id="infra-process-modal-sub" class="ticket-meta"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-close-infra-process-modal>Fechar</button>
|
||||||
|
</div>
|
||||||
|
<div id="infra-process-modal-body" class="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/assets/auth.js?v=20260619tickets3"></script>
|
<script src="/assets/auth.js?v=20260619code1"></script>
|
||||||
<script src="/assets/modules.js?v=20260619tickets3"></script>
|
<script src="/assets/modules.js?v=20260619code1"></script>
|
||||||
<script src="/assets/billing-ui.js?v=20260619tickets3"></script>
|
<script src="/assets/billing-ui.js?v=20260619code1"></script>
|
||||||
<script src="/assets/desk-live-stub.js?v=20260619tickets3"></script>
|
<script src="/assets/desk-live-stub.js?v=20260619code1"></script>
|
||||||
<script src="/assets/tickets-workspace.js?v=20260619tickets3"></script>
|
<script src="/assets/tickets-workspace.js?v=20260619code1"></script>
|
||||||
<script src="/assets/tickets-detail-panel.js?v=20260619tickets3"></script>
|
<script src="/assets/tickets-detail-panel.js?v=20260619code1"></script>
|
||||||
<script src="/assets/servicos.js?v=20260619tickets3"></script>
|
<script src="/assets/servicos.js?v=20260619code1"></script>
|
||||||
<script src="/assets/app.js?v=20260619tickets3"></script>
|
<script src="/assets/app.js?v=20260619code1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ Permitir validar **quantas vezes quiser** que a re-engenharia OpenPanel Ligbox e
|
||||||
|
|
||||||
## Menu Infra (Desk VM122)
|
## Menu Infra (Desk VM122)
|
||||||
|
|
||||||
|
**Layout UI:** Spec **033** (`specs/033-desk-infra-console-ui/spec.md`) — painel largo `OpenPanel API — Re-engenharia Ligbox · Spec 028` na grid Infra.
|
||||||
|
|
||||||
1. Login em https://desk.ligbox.com.br
|
1. Login em https://desk.ligbox.com.br
|
||||||
2. Menu lateral → **Infraestrutura**
|
2. Menu lateral → **Infraestrutura**
|
||||||
3. Card **OpenPanel API — Re-engenharia Ligbox**
|
3. Painel **OpenPanel API — Re-engenharia Ligbox** (chip VM123 no topo + painel orange largura total)
|
||||||
4. Botão **Testar multidomínio**
|
4. Botão **Testar multidomínio**
|
||||||
5. Modal com passo-a-passo e resultado (OK/FAIL por step)
|
5. Modal com passo-a-passo e resultado (OK/FAIL por step)
|
||||||
|
|
||||||
|
|
|
||||||
93
specs/033-desk-infra-console-ui/spec.md
Normal file
93
specs/033-desk-infra-console-ui/spec.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Spec 033 — Desk Infra Console UI (process cards)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-19
|
||||||
|
**Actualizado:** 2026-06-19 (padronização `proc-card`)
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Prioridade:** P2 (UX operacional)
|
||||||
|
**Status:** ✅ Implementado (Desk VM122 frontend)
|
||||||
|
**Sistema:** Desk VM122 · menu **INFRA CODE** (`view-infra`)
|
||||||
|
**Relacionado:** Spec **028** (OpenPanel API) · Spec **032** (códigos purge) · Spec **002** (Wazuh) · módulo `infra` (015)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
Página **Infraestrutura** do Desk com **process cards** uniformes (`proc-card`): mesmo tamanho, tipografia, ícone, badge de status e acções no rodapé. Conteúdo rico (métricas, formulários, JSON) abre em **modal largo** (`#infra-process-modal`).
|
||||||
|
|
||||||
|
**Motivo:** painéis `ws-panel` de alturas variadas quebravam o alinhamento visual; inputs inline (purge) inflavam cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referência de design (pesquisa)
|
||||||
|
|
||||||
|
Padrões adoptados de boas práticas de UI card (Material UI equal-height, UX Collective, CSS Grid auto-fill):
|
||||||
|
|
||||||
|
| Regra | Valor Desk |
|
||||||
|
|-------|------------|
|
||||||
|
| Grid | `repeat(auto-fill, minmax(220px, 1fr))` · gap **16px** (sistema 8pt) |
|
||||||
|
| Altura mínima card | **168px** · `flex-column` + `justify` implícito via footer |
|
||||||
|
| Padding card | **16px** |
|
||||||
|
| Título | **0.88rem** · weight 600 |
|
||||||
|
| Spec label | **0.62rem** uppercase · letter-spacing 0.05em |
|
||||||
|
| Descrição | **0.75rem** · **2 linhas** (`line-clamp: 2`) |
|
||||||
|
| Ícone | **32×32px** · fundo pastel por accent |
|
||||||
|
| Badge status | canto superior direito · classes `badge` existentes |
|
||||||
|
| Inputs / tabelas / JSON | **modal** `modal-panel-lg` — nunca no card |
|
||||||
|
| Testes (webhook, OpenPanel) | botão rápido no card **ou** no modal · resultado em `#soc-test-modal` |
|
||||||
|
|
||||||
|
**Alinhamento igual altura:** grid `align-items: stretch` + `min-height` fixo no card (não aspect-ratio — conteúdo operacional, não media).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Onde na UI
|
||||||
|
|
||||||
|
| Menu Desk | View ID | Render |
|
||||||
|
|-------------|---------|--------|
|
||||||
|
| INFRA CODE | `view-infra` | `renderInfra()` em `frontend/assets/app.js` |
|
||||||
|
|
||||||
|
**Não** cobre Infra 2 / SOC visual (`view-infra2` → `renderInfra2()`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Catálogo de process cards (Infra)
|
||||||
|
|
||||||
|
Endpoint agregado: `GET /api/v1/infra/stack/status` — probes paralelos de **todos** os serviços do stack (apps, APIs, SW) nas VMs **112, 114, 122, 123, 130**.
|
||||||
|
|
||||||
|
Implementação: `api/app/stack_health.py` + `infra_stack_routes.py`.
|
||||||
|
|
||||||
|
| VM | Serviços monitorados |
|
||||||
|
|----|---------------------|
|
||||||
|
| **112** | Onboard API, Wizard UI, Carbonio, Domain Admin API |
|
||||||
|
| **114** | Traefik API, Router Desk WAN, Router API Ops WAN |
|
||||||
|
| **122** | Desk API, Desk UI, Redis, Webhook SOC, Purge Auth API |
|
||||||
|
| **123** | FOSSBilling, Odoo, OpenPanel, OpenAdmin, Bridge :18087, Ops Console, phpMyAdmin, Ollama |
|
||||||
|
| **130** | Forgejo, Spec Portal, Spec Hub WAN |
|
||||||
|
|
||||||
|
UI: secções por VM + `proc-card` uniforme · modal para detalhes/formulários.
|
||||||
|
|
||||||
|
Menu: **INFRA CODE** · subtítulo **Serviços IaaS · Infra as Code** · nav **Serviços IaaS** (`overview-home`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modais
|
||||||
|
|
||||||
|
| Modal | Uso |
|
||||||
|
|-------|-----|
|
||||||
|
| `#infra-process-modal` | Detalhes, formulário purge, JSON integrações |
|
||||||
|
| `#soc-test-modal` | Resultado testes webhook / OpenPanel multidomínio |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceitação
|
||||||
|
|
||||||
|
1. **INFRA CODE** exibe cards de **todas** as VMs 112/114/122/123/130.
|
||||||
|
2. Cada card: ícone, spec/kind, título, status, 2 linhas de detalhe.
|
||||||
|
3. Purge / inputs apenas no modal.
|
||||||
|
4. Summary `N/M online` no topo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusão
|
||||||
|
|
||||||
|
**Spec 033** = layout INFRA CODE + stack health. Funcionalidades: OpenPanel **028**, purge **032**, webhook **001**.
|
||||||
|
|
||||||
Loading…
Reference in a new issue