Compare commits

..

No commits in common. "50085b7d9406ba4c2d28910c3cedddc6a919a2e7" and "41c0c2d42868b3bde19974e2f0e26b21cc9b33fe" have entirely different histories.

19 changed files with 123 additions and 2445 deletions

View file

@ -1,38 +0,0 @@
"""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

View file

@ -26,8 +26,6 @@ from app.carbonio_release_routes import router as carbonio_release_router
from app.migration.router import router as migration_router
from app.billing_routes import router as billing_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.permissions import (
can_assign_ticket,
@ -131,8 +129,6 @@ app.include_router(security_router)
app.include_router(carbonio_release_router)
app.include_router(migration_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"

View file

@ -31,14 +31,14 @@ MODULES: tuple[ModuleDef, ...] = (
),
ModuleDef(
id="overview-home",
label="Serviços IaaS",
description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018) · Infra as Code.",
label="Serviços",
description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018).",
nav_views=("overview-home",),
),
ModuleDef(
id="infra",
label="INFRA CODE",
description="Infrastructure as Code — stack VMs 112/114/122/123/130 (Spec 033).",
label="Infra",
description="Health VM112, VM104 e integrações técnicas.",
nav_views=("infra",),
),
ModuleDef(

View file

@ -1,447 +0,0 @@
"""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())],
}

View file

@ -1,5 +0,0 @@
"""VM123 integration — Spec 027 Fase 3."""
from app.vm123.routes import router as vm123_router
__all__ = ["vm123_router"]

View file

@ -1,98 +0,0 @@
"""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,
}

View file

@ -1,240 +0,0 @@
"""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,
}

View file

@ -1,118 +0,0 @@
"""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

View file

@ -1,210 +0,0 @@
"""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"
),
}

View file

@ -1,281 +0,0 @@
"""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),
}

View file

@ -1,71 +0,0 @@
"""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

View file

@ -1,94 +0,0 @@
"""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 {}

View file

@ -1,33 +0,0 @@
"""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",
}

View file

@ -1,290 +0,0 @@
"""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

View file

@ -204,11 +204,11 @@ function setView(name) {
const titles = {
dashboard: 'Dashboard',
overview: 'Audit Overview',
'overview-home': 'Serviços IaaS',
'overview-home': 'Serviços',
tickets: 'Tickets',
events: 'Eventos webhook',
tenants: 'Tenants',
infra: 'INFRA CODE',
infra: 'Infraestrutura',
infra2: 'SOC — Infra 2',
messages: 'Mensagens — pedidos de cadastro',
admin: 'Administradores',
@ -219,11 +219,11 @@ function setView(name) {
const subtitles = {
dashboard: 'Operações Ligbox — onboarding, tickets e monitoramento',
overview: 'Visão por tenant — cards de auditoria (versão clássica)',
'overview-home': 'Orquestração MOSP · Infra as Code',
'overview-home': 'Desk VM122 · Orquestração MOSP',
tickets: 'Operações Ligbox — onboarding, tickets e monitoramento',
events: 'Operações Ligbox — onboarding, tickets e monitoramento',
tenants: 'Operações Ligbox — onboarding, tickets e monitoramento',
infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130',
infra: 'VM112, VM104 e integrações — visão técnica',
infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real',
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
@ -3825,282 +3825,122 @@ function infraKvHtml(items) {
).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;
async function renderInfra() {
const el = document.getElementById('infra-content');
el.innerHTML = '<p class="loading">Verificando…</p>';
try {
const [vm112, wazuh, integrations, health] = await Promise.all([
api('/v1/infra/vm112/status'),
api('/v1/infra/wazuh/status'),
api('/v1/integrations'),
api('/v1/integrations/health'),
]);
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 wazuhOk = wazuh.http_status === 200;
const statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting';
const heroHealthDot = health.status === 'ok' ? '' : health.status === 'critical' ? 'infra-hero-dot--bad' : 'infra-hero-dot--warn';
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([
el.innerHTML = `
<div class="infra-page">
<div class="infra-hero">
<div class="infra-hero-chip">
<span class="infra-hero-dot ${heroHealthDot}" aria-hidden="true"></span>
<div class="infra-hero-body">
<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')],
['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>
<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 &gt; ${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 || '—')],
])}
<p class="infra-hint">Alerta se gap &gt; ${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-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>
<button type="button" class="btn secondary btn-sm" id="btn-test-openpanel-api">Testar multidomínio</button>
</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() {
const el = document.getElementById('infra-content');
el.innerHTML = '<p class="loading">Verificando stack…</p>';
try {
const [stack, integrations, health, vm123Health, purgeMeta] = await Promise.all([
api('/v1/infra/stack/status'),
api('/v1/integrations').catch(() => null),
api('/v1/integrations/health').catch(() => ({})),
api('/v1/vm123/health').catch(() => null),
api('/v1/infra/purge-auth-domains').catch(() => ({ domains: [], can_generate: false })),
]);
state.infraSnapshot = { stack, integrations, health, vm123Health, purgeMeta };
const summary = stack.summary || {};
const okCls = summary.ok === summary.total ? 'ok' : summary.ok > 0 ? 'assisting' : 'escalated';
let sections = (stack.vms || []).map((vm) => {
const cards = (vm.services || []).map((svc) =>
procCardHtml({
id: svc.id,
icon: svc.icon || '⚙️',
accent: svc.accent || 'teal',
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 = `
<div class="infra-page">
<div class="infra-stack-summary">
<span class="badge ${okCls}">${summary.ok ?? 0}/${summary.total ?? 0} online</span>
<span class="infra-hint">Stack Ligbox · VMs 112 · 114 · 122 · 123 · 130 · Infra as Code · ${fmtDate(stack.generated_at)}</span>
<button type="button" class="btn btn-ghost btn-sm" id="btn-infra-refresh-stack">Atualizar stack</button>
</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>` : ''}
</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>`;
document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra());
bindStackCardActions(el);
document.getElementById('btn-refresh-health')?.addEventListener('click', () => renderInfra());
document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra'));
document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest());
await renderPurgeAuthInfraPanel();
} catch (e) {
el.innerHTML = `<p class="loading">Erro: ${esc(e.message)}</p>`;
}
}
async function renderPurgeAuthPanel(panel) {
async function renderPurgeAuthInfraPanel() {
const panel = document.getElementById('purge-auth-infra-panel');
if (!panel) return;
try {
const meta = await api('/v1/infra/purge-auth-domains');
@ -4198,10 +4038,6 @@ async function renderPurgeAuthPanel(panel) {
}
}
async function renderPurgeAuthInfraPanel() {
await renderPurgeAuthPanel(document.getElementById('purge-auth-infra-panel'));
}
async function refresh(options = {}) {
const { poll = false } = options;
await loadHealth();
@ -4285,7 +4121,6 @@ document.getElementById('btn-refresh')?.addEventListener('click', () => {
applyRoleNav();
DeskModules.applyVisibility();
bindOverviewModal();
bindInfraProcessModal();
bindTeamDrawerClose();
bindSocTestModal();
setView('dashboard');

View file

@ -3901,26 +3901,6 @@ button.health-card {
flex-direction: column;
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 {
display: flex;
flex-wrap: wrap;
@ -3938,14 +3918,10 @@ button.health-card {
background: linear-gradient(135deg, #f0fdfa 0%, #fff 55%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.infra-hero-chip--openpanel {
.infra-hero-chip--alert {
background: linear-gradient(135deg, #fff7ed 0%, #fff 55%);
border-color: #fed7aa;
}
.infra-panel--featured {
border-color: #fdba74;
box-shadow: 0 4px 14px rgba(234, 88, 12, 0.12);
}
.infra-hero-dot {
width: 10px;
height: 10px;
@ -4131,102 +4107,6 @@ button.health-card {
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) */
.ws-access-zone {
margin-bottom: 1.25rem;

View file

@ -194,7 +194,7 @@
</button>
<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-label">Serviços IaaS</span>
<span class="nav-label">Serviços</span>
</button>
<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>
@ -218,7 +218,7 @@
</button>
<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-label">INFRA CODE</span>
<span class="nav-label">Infra</span>
</button>
<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>
@ -413,27 +413,14 @@
</aside>
</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=20260619code1"></script>
<script src="/assets/modules.js?v=20260619code1"></script>
<script src="/assets/billing-ui.js?v=20260619code1"></script>
<script src="/assets/desk-live-stub.js?v=20260619code1"></script>
<script src="/assets/tickets-workspace.js?v=20260619code1"></script>
<script src="/assets/tickets-detail-panel.js?v=20260619code1"></script>
<script src="/assets/servicos.js?v=20260619code1"></script>
<script src="/assets/app.js?v=20260619code1"></script>
<script src="/assets/auth.js?v=20260619tickets3"></script>
<script src="/assets/modules.js?v=20260619tickets3"></script>
<script src="/assets/billing-ui.js?v=20260619tickets3"></script>
<script src="/assets/desk-live-stub.js?v=20260619tickets3"></script>
<script src="/assets/tickets-workspace.js?v=20260619tickets3"></script>
<script src="/assets/tickets-detail-panel.js?v=20260619tickets3"></script>
<script src="/assets/servicos.js?v=20260619tickets3"></script>
<script src="/assets/app.js?v=20260619tickets3"></script>
</body>
</html>

View file

@ -29,11 +29,9 @@ Permitir validar **quantas vezes quiser** que a re-engenharia OpenPanel Ligbox e
## 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
2. Menu lateral → **Infraestrutura**
3. Painel **OpenPanel API — Re-engenharia Ligbox** (chip VM123 no topo + painel orange largura total)
3. Card **OpenPanel API — Re-engenharia Ligbox**
4. Botão **Testar multidomínio**
5. Modal com passo-a-passo e resultado (OK/FAIL por step)

View file

@ -1,93 +0,0 @@
# 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**.