diff --git a/projects/ops-desk/api/app/infra_stack_routes.py b/projects/ops-desk/api/app/infra_stack_routes.py new file mode 100644 index 0000000..c177fab --- /dev/null +++ b/projects/ops-desk/api/app/infra_stack_routes.py @@ -0,0 +1,38 @@ +"""Rotas INFRA COD — 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 diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index e66a73e..3f85a03 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -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.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, @@ -129,6 +131,8 @@ 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" diff --git a/projects/ops-desk/api/app/modules/registry.py b/projects/ops-desk/api/app/modules/registry.py index 884252a..59311c8 100644 --- a/projects/ops-desk/api/app/modules/registry.py +++ b/projects/ops-desk/api/app/modules/registry.py @@ -31,14 +31,14 @@ MODULES: tuple[ModuleDef, ...] = ( ), ModuleDef( id="overview-home", - label="Serviços", - description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018).", + label="Serviços IaaS", + description="Orquestração de serviços — clientes, catálogo cPanel e purge OPS (Spec 018) · Infra as Code.", nav_views=("overview-home",), ), ModuleDef( id="infra", - label="Infra", - description="Health VM112, VM104 e integrações técnicas.", + label="INFRA COD", + description="Stack completo VMs 112/114/122/123/130 — apps, APIs e integrações (Spec 033).", nav_views=("infra",), ), ModuleDef( diff --git a/projects/ops-desk/api/app/stack_health.py b/projects/ops-desk/api/app/stack_health.py new file mode 100644 index 0000000..4fcb1bd --- /dev/null +++ b/projects/ops-desk/api/app/stack_health.py @@ -0,0 +1,446 @@ +"""Stack health probes — VMs 112, 114, 122, 123, 130 (Spec 033 / INFRA COD).""" + +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_API = os.getenv("VM112_API_URL", "http://10.10.10.112: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())], + } diff --git a/projects/ops-desk/api/app/vm123/__init__.py b/projects/ops-desk/api/app/vm123/__init__.py new file mode 100644 index 0000000..6ebf0d0 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/__init__.py @@ -0,0 +1,5 @@ +"""VM123 integration — Spec 027 Fase 3.""" + +from app.vm123.routes import router as vm123_router + +__all__ = ["vm123_router"] diff --git a/projects/ops-desk/api/app/vm123/foss_client.py b/projects/ops-desk/api/app/vm123/foss_client.py new file mode 100644 index 0000000..6711879 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/foss_client.py @@ -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, + } diff --git a/projects/ops-desk/api/app/vm123/odoo_client.py b/projects/ops-desk/api/app/vm123/odoo_client.py new file mode 100644 index 0000000..bc2f83c --- /dev/null +++ b/projects/ops-desk/api/app/vm123/odoo_client.py @@ -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, + } diff --git a/projects/ops-desk/api/app/vm123/openpanel_client.py b/projects/ops-desk/api/app/vm123/openpanel_client.py new file mode 100644 index 0000000..5ee440e --- /dev/null +++ b/projects/ops-desk/api/app/vm123/openpanel_client.py @@ -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 diff --git a/projects/ops-desk/api/app/vm123/openpanel_test.py b/projects/ops-desk/api/app/vm123/openpanel_test.py new file mode 100644 index 0000000..c153f9d --- /dev/null +++ b/projects/ops-desk/api/app/vm123/openpanel_test.py @@ -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" + ), + } diff --git a/projects/ops-desk/api/app/vm123/permissions.py b/projects/ops-desk/api/app/vm123/permissions.py new file mode 100644 index 0000000..ef93de4 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/permissions.py @@ -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), + } diff --git a/projects/ops-desk/api/app/vm123/provision.py b/projects/ops-desk/api/app/vm123/provision.py new file mode 100644 index 0000000..dadfd99 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/provision.py @@ -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 diff --git a/projects/ops-desk/api/app/vm123/provision_store.py b/projects/ops-desk/api/app/vm123/provision_store.py new file mode 100644 index 0000000..1786c06 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/provision_store.py @@ -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 {} diff --git a/projects/ops-desk/api/app/vm123/role_map.py b/projects/ops-desk/api/app/vm123/role_map.py new file mode 100644 index 0000000..3863815 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/role_map.py @@ -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", +} diff --git a/projects/ops-desk/api/app/vm123/routes.py b/projects/ops-desk/api/app/vm123/routes.py new file mode 100644 index 0000000..ae42be7 --- /dev/null +++ b/projects/ops-desk/api/app/vm123/routes.py @@ -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 diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 08617d1..94d8cb7 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -204,11 +204,11 @@ function setView(name) { const titles = { dashboard: 'Dashboard', overview: 'Audit Overview', - 'overview-home': 'Serviços', + 'overview-home': 'Serviços IaaS', tickets: 'Tickets', events: 'Eventos webhook', tenants: 'Tenants', - infra: 'Infraestrutura', + infra: 'INFRA COD', 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': 'Desk VM122 · Orquestração MOSP', + 'overview-home': 'Orquestração MOSP · Infra as Code', 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: 'VM112, VM104 e integrações — visão técnica', + infra: 'Serviços IaaS · Infra as Code — stack VMs 112, 114, 122, 123, 130', 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', @@ -3847,10 +3847,10 @@ function procCardHtml(opts) { actions = [], } = opts; const acts = actions.map((a) => - `` + `` ).join(''); return ` -
+
${esc(statusLabel)}
@@ -3891,23 +3891,84 @@ function bindInfraProcessModal() { }); } +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', `${esc(svc.url || '—')}`], + ['Status', esc(svc.status || '—')], + ['HTTP', svc.http_status != null ? String(svc.http_status) : '—'], + ['Detalhe', esc(svc.detail || '—')], + ])} +
+ ${svc.url && svc.url.startsWith('http') ? `Abrir URL` : ''} +
` + ); +} + function openInfraProcessDetail(procId) { - const snap = state.infraSnapshot; - if (!snap) return; - const { vm112, wazuh, integrations, health, vm123Health, purgeMeta } = snap; + 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 wazuhOk = wazuh.http_status === 200; - const op = vm123Health?.openpanel || {}; - const opOk = Boolean(op.ok); - const bridgeOk = Boolean(op.bridge); const alerts = (health.alerts || []).map((a) => `
  • ${esc(a.message)}
  • ` ).join('') || '
  • Nenhum alerta activo
  • '; - if (procId === 'soc') { + if (procId === 'soc' || procId === 'vm122-webhook-soc') { openInfraProcessModal( 'SOC — Integração VM112', 'Webhook onboard · alertas de gap', @@ -3932,19 +3993,20 @@ function openInfraProcessDetail(procId) { }); return; } - if (procId === 'openpanel') { + 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', `

    Multidomínio · conta temporária com cleanup automático.

    - ${vm123Health - ? infraKvHtml([ - ['OpenPanel', opOk ? 'OK' : esc(op.error || 'offline')], - ['Bridge API', bridgeOk ? 'OK' : 'offline'], - ['Bridge URL', esc(op.bridge_url || '—')], - ['VM123', vm123Health.ok ? 'OK' : esc(vm123Health.error || 'check')], - ]) - : '

    Status VM123 indisponível.

    '} + ${infraKvHtml([ + ['OpenPanel', opOk ? 'OK' : esc(op.error || op.detail || 'offline')], + ['Bridge API', bridgeOk ? 'OK' : 'offline'], + ['Bridge URL', esc(op.bridge_url || op.url || '—')], + ])}
    OpenPanel UI @@ -3954,7 +4016,7 @@ function openInfraProcessDetail(procId) { document.getElementById('btn-test-openpanel-modal')?.addEventListener('click', () => runOpenPanelApiTest()); return; } - if (procId === 'purge') { + if (procId === 'purge' || procId === 'vm122-purge-auth') { openInfraProcessModal( 'Códigos purge — autorização extra', 'Spec 032 · domínios protegidos', @@ -3963,159 +4025,76 @@ function openInfraProcessDetail(procId) { renderPurgeAuthPanel(document.getElementById('purge-auth-modal-panel')); return; } - if (procId === 'vm112') { - openInfraProcessModal( - 'VM112 — Onboard Portal', - 'HTTP health · serviço portal', - infraKvHtml([ - ['HTTP', String(vm112.http_status ?? '—')], - ['Service', esc(vm112.vm112?.service || vm112.error || '—')], - ['API integração', vmOk ? 'OK' : 'offline'], - ]) - ); - return; - } - if (procId === 'wazuh') { - openInfraProcessModal( - 'VM104 — Wazuh SOC', - 'Spec 002 · API + webhook', - infraKvHtml([ - ['API HTTP', String(wazuh.http_status ?? '—')], - ['Integração', 'webhook level ≥ 10 → VM122'], - ['Status', wazuhOk ? 'online' : 'check'], - ]) - ); - return; - } if (procId === 'integrations') { openInfraProcessModal( 'Integrações activas', 'Snapshot JSON · Desk API', - `
    ${esc(JSON.stringify(integrations, null, 2))}
    ` + `
    ${esc(JSON.stringify(integrations || {}, null, 2))}
    ` ); } } async function renderInfra() { const el = document.getElementById('infra-content'); - el.innerHTML = '

    Verificando…

    '; + el.innerHTML = '

    Verificando stack…

    '; try { - const [vm112, wazuh, integrations, health, vm123Health, purgeMeta] = await Promise.all([ - api('/v1/infra/vm112/status'), - api('/v1/infra/wazuh/status'), - api('/v1/integrations'), - api('/v1/integrations/health'), + 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 = { vm112, wazuh, integrations, health, vm123Health, purgeMeta }; - 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 op = vm123Health?.openpanel || {}; - const opOk = Boolean(op.ok); - const bridgeOk = Boolean(op.bridge); - const statusCls = health.status === 'ok' ? 'ok' : health.status === 'critical' ? 'escalated' : 'assisting'; - const purgeDomains = purgeMeta.domains || []; - const purgeLabel = purgeDomains.length ? `${purgeDomains.length} domínio(s)` : 'sem extra-auth'; + 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 ` +
    +

    ${esc(vm.vm_label)} ${esc(vm.ip)}

    +
    ${cards}
    +
    `; + }).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 = `
    -

    Processos de infraestrutura · cards uniformes · detalhes e formulários em modal (Spec 033).

    -
    - ${procCardHtml({ - id: 'soc', - icon: PROC_CARD_ICONS.soc, - accent: 'teal', - title: 'SOC VM112', - spec: 'Webhook', - desc: `Gap ${gap} · ${last?.event ? esc(last.event) : 'sem eventos recentes'}`, - statusLabel: health.status || '—', - statusCls, - actions: [ - { id: 'btn-proc-soc-detail', label: 'Detalhes', primary: true }, - { id: 'btn-test-webhook', label: 'Testar', primary: false }, - ], - })} - ${procCardHtml({ - id: 'openpanel', - icon: PROC_CARD_ICONS.openpanel, - accent: 'orange', - title: 'OpenPanel API', - spec: 'Spec 028', - desc: `Bridge ${bridgeOk ? 'OK' : 'check'} · ${esc(op.bridge_url || '10.10.10.123:18087')}`, - statusLabel: opOk ? 'online' : 'check', - statusCls: opOk ? 'ok' : 'review', - actions: [ - { id: 'btn-proc-openpanel-detail', label: 'Detalhes', primary: true }, - { id: 'btn-test-openpanel-api', label: 'Testar', primary: false }, - ], - })} - ${procCardHtml({ - id: 'purge', - icon: PROC_CARD_ICONS.purge, - accent: 'rose', - title: 'Códigos purge', - spec: 'Spec 032', - desc: purgeDomains.length - ? `Protegidos: ${purgeDomains.map((d) => esc(d)).join(', ')}` - : 'Geração de códigos para domínios com autorização extra', - statusLabel: purgeLabel, - statusCls: purgeDomains.length ? 'assisting' : 'open', - actions: [ - { id: 'btn-proc-purge-manage', label: 'Gerir códigos', primary: true }, - ], - })} - ${procCardHtml({ - id: 'vm112', - icon: PROC_CARD_ICONS.vm112, - accent: 'aqua', - title: 'VM112 Onboard', - spec: 'Portal', - desc: esc(vm112.vm112?.service || vm112.error || 'Portal de onboarding'), - statusLabel: vmOk ? 'online' : 'check', - statusCls: vmOk ? 'ok' : 'review', - actions: [ - { id: 'btn-proc-vm112-detail', label: 'Ver status', primary: true }, - ], - })} - ${procCardHtml({ - id: 'wazuh', - icon: PROC_CARD_ICONS.wazuh, - accent: 'slate', - title: 'Wazuh SOC', - spec: 'Spec 002', - desc: `API HTTP ${wazuh.http_status ?? '—'} · alertas nível ≥ 10`, - statusLabel: wazuhOk ? 'online' : 'check', - statusCls: wazuhOk ? 'ok' : 'review', - actions: [ - { id: 'btn-proc-wazuh-detail', label: 'Ver status', primary: true }, - ], - })} - ${procCardHtml({ - id: 'integrations', - icon: PROC_CARD_ICONS.integrations, - accent: 'violet', - title: 'Integrações', - spec: 'JSON', - desc: 'Snapshot das integrações configuradas no Desk', - statusLabel: 'activas', - statusCls: 'open', - actions: [ - { id: 'btn-proc-integrations-json', label: 'Ver JSON', primary: true }, - ], - })} +
    + ${summary.ok ?? 0}/${summary.total ?? 0} online + Stack Ligbox · VMs 112 · 114 · 122 · 123 · 130 · Infra as Code · ${fmtDate(stack.generated_at)} +
    + ${sections} + ${integrationsCard ? `

    Integrações · Desk API

    ${integrationsCard}
    ` : ''}
    `; - document.getElementById('btn-test-webhook')?.addEventListener('click', () => runWebhookIntegrationTest('infra')); - document.getElementById('btn-test-openpanel-api')?.addEventListener('click', () => runOpenPanelApiTest()); - document.getElementById('btn-proc-soc-detail')?.addEventListener('click', () => openInfraProcessDetail('soc')); - document.getElementById('btn-proc-openpanel-detail')?.addEventListener('click', () => openInfraProcessDetail('openpanel')); - document.getElementById('btn-proc-purge-manage')?.addEventListener('click', () => openInfraProcessDetail('purge')); - document.getElementById('btn-proc-vm112-detail')?.addEventListener('click', () => openInfraProcessDetail('vm112')); - document.getElementById('btn-proc-wazuh-detail')?.addEventListener('click', () => openInfraProcessDetail('wazuh')); - document.getElementById('btn-proc-integrations-json')?.addEventListener('click', () => openInfraProcessDetail('integrations')); + document.getElementById('btn-infra-refresh-stack')?.addEventListener('click', () => renderInfra()); + bindStackCardActions(el); } catch (e) { el.innerHTML = `

    Erro: ${esc(e.message)}

    `; } diff --git a/projects/ops-desk/frontend/assets/styles.css b/projects/ops-desk/frontend/assets/styles.css index 733c50f..2e4afcf 100644 --- a/projects/ops-desk/frontend/assets/styles.css +++ b/projects/ops-desk/frontend/assets/styles.css @@ -3901,6 +3901,26 @@ 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; diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index 67ec74d..abf207f 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -194,7 +194,7 @@
    - - - - - - - - + + + + + + + + diff --git a/specs/033-desk-infra-console-ui/spec.md b/specs/033-desk-infra-console-ui/spec.md index cad45a6..18ee77c 100644 --- a/specs/033-desk-infra-console-ui/spec.md +++ b/specs/033-desk-infra-console-ui/spec.md @@ -5,7 +5,7 @@ **Solicitado por:** Roger **Prioridade:** P2 (UX operacional) **Status:** ✅ Implementado (Desk VM122 frontend) -**Sistema:** Desk VM122 · menu **Infraestrutura** (`view-infra`) +**Sistema:** Desk VM122 · menu **INFRA COD** (`view-infra`) **Relacionado:** Spec **028** (OpenPanel API) · Spec **032** (códigos purge) · Spec **002** (Wazuh) · módulo `infra` (015) --- @@ -43,7 +43,7 @@ Padrões adoptados de boas práticas de UI card (Material UI equal-height, UX Co | Menu Desk | View ID | Render | |-------------|---------|--------| -| Infraestrutura | `view-infra` | `renderInfra()` em `frontend/assets/app.js` | +| INFRA COD | `view-infra` | `renderInfra()` em `frontend/assets/app.js` | **Não** cobre Infra 2 / SOC visual (`view-infra2` → `renderInfra2()`). @@ -51,16 +51,21 @@ Padrões adoptados de boas práticas de UI card (Material UI equal-height, UX Co ## Catálogo de process cards (Infra) -| ID | Ícone | Título | Spec label | Accent | Status | Acções card | -|----|-------|--------|------------|--------|--------|-------------| -| `soc` | 📡 | SOC VM112 | Webhook | teal | `health.status` | Detalhes · Testar | -| `openpanel` | 🎛️ | OpenPanel API | Spec 028 | orange | online/check | Detalhes · Testar | -| `purge` | 🔐 | Códigos purge | Spec 032 | rose | N domínios | Gerir códigos → modal | -| `vm112` | 🌐 | VM112 Onboard | Portal | aqua | online/check | Ver status | -| `wazuh` | 🛡️ | Wazuh SOC | Spec 002 | slate | online/check | Ver status | -| `integrations` | 🔗 | Integrações | JSON | violet | activas | Ver JSON | +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**. -Mapa de ícones: constante `PROC_CARD_ICONS` em `app.js`. +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 COD** · subtítulo **Serviços IaaS · Infra as Code** · nav **Serviços IaaS** (`overview-home`). --- @@ -71,71 +76,18 @@ Mapa de ícones: constante `PROC_CARD_ICONS` em `app.js`. | `#infra-process-modal` | Detalhes, formulário purge, JSON integrações | | `#soc-test-modal` | Resultado testes webhook / OpenPanel multidomínio | -Funções: `openInfraProcessModal()`, `openInfraProcessDetail(procId)`, `closeInfraProcessModal()`, `bindInfraProcessModal()`. - -Snapshot API: `state.infraSnapshot` (reutilizado ao abrir detalhe sem re-fetch). - ---- - -## APIs consumidas - -| Endpoint | Uso | -|----------|-----| -| `GET /api/v1/infra/vm112/status` | Card VM112 + modal | -| `GET /api/v1/infra/wazuh/status` | Card Wazuh + modal | -| `GET /api/v1/integrations` | Card + modal JSON | -| `GET /api/v1/integrations/health` | Card SOC + modal | -| `GET /api/v1/vm123/health` | Card OpenPanel + modal | -| `GET /api/v1/infra/purge-auth-domains` | Card purge + modal | -| `GET/POST /api/v1/infra/purge-auth-codes` | Modal purge (Spec 032) | -| `POST /api/v1/vm123/openpanel/test-confirm` | Teste OpenPanel | - ---- - -## CSS (`frontend/assets/styles.css`) - -Classes **proc-card** (padrão reutilizável em outras páginas): - -- `.proc-grid`, `.proc-card`, `.proc-card--{teal|orange|rose|slate|violet|aqua}` -- `.proc-card-head`, `.proc-card-icon`, `.proc-card-spec`, `.proc-card-badge` -- `.proc-card-title`, `.proc-card-desc`, `.proc-card-foot` - -Helpers Infra (modais): `.infra-kv`, `.infra-actions`, `.infra-hint`, `.purge-auth-form` - -Helper JS: `procCardHtml(opts)` — gera HTML uniforme. - ---- - -## Ficheiros - -| Ficheiro | Função | -|----------|--------| -| `frontend/assets/app.js` | `renderInfra()`, `procCardHtml()`, modais Infra | -| `frontend/assets/styles.css` | `.proc-card` + Infra | -| `frontend/index.html` | `#infra-content`, `#infra-process-modal` | - --- ## Critérios de aceitação -1. Grid com **6 cards** de **mesma altura mínima** e alinhamento visual. -2. Cada card: ícone + spec label + título + descrição (2 linhas) + badge + acções. -3. Purge: formulário e tabela **só no modal** «Gerir códigos». -4. OpenPanel / SOC: teste rápido no card; detalhes no modal. -5. Responsivo: 1 coluna em mobile (`max-width: 480px`). - ---- - -## Extensão futura - -O sistema `proc-card` pode ser adoptado em **Serviços** (substituir `servicos-tile` gradualmente) e **Dashboard** — manter o mesmo catálogo de accents e ícones. +1. **INFRA COD** 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 e padronização visual da Infra. Funcionalidades: +**Spec 033** = layout INFRA COD + stack health. Funcionalidades: OpenPanel **028**, purge **032**, webhook **001**. -- OpenPanel → **Spec 028** -- Códigos purge → **Spec 032** -- Webhook SOC → health integrations / Spec **001**