diff --git a/BACKLOG.md b/BACKLOG.md index 490be29..8b77361 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -123,7 +123,7 @@ | **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ | | **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 | | **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 | -| **AG-1** | P3 | Agentes IA + runbooks | 📋 | +| **AG-1** | P1 | Agentes IA + runbooks (Spec 029) | 🔄 staging | --- diff --git a/deploy/vm122-agentic-staging/deploy-staging.sh b/deploy/vm122-agentic-staging/deploy-staging.sh new file mode 100755 index 0000000..23219ed --- /dev/null +++ b/deploy/vm122-agentic-staging/deploy-staging.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Deploy homologação Agentic Ops — VM122 staging (portas 8180/8192) +# NÃO altera produção em :8080/:8091 +set -euo pipefail + +STAGING_DIR="/opt/ligbox-ops-platform-staging" +REPO="/opt/ligbox-spec-hub/repos/ligbox-ops-platform" +BRANCH="${1:-029-agentic-ops-runbooks}" + +echo "==> Staging Agentic Ops branch=$BRANCH" + +mkdir -p "$STAGING_DIR" /var/lib/ligbox-ops-platform-staging + +# Sync código (symlinks api/frontend/worker do repo) +rsync -a --delete \ + --exclude '.git' --exclude 'chat-bruto' --exclude 'node_modules' \ + "$REPO/projects/ops-desk/" "$STAGING_DIR/" + +rsync -a "$REPO/specs/" "$STAGING_DIR/specs/" + +cd "$STAGING_DIR" + +if [[ ! -f .env ]]; then + if [[ -f /opt/ligbox-ops-platform/.env ]]; then + cp /opt/ligbox-ops-platform/.env .env + sed -i 's|SQLITE_PATH=.*|SQLITE_PATH=/data/ops-staging.db|' .env + echo "AGENTIC_LLM_ENABLED=true" >> .env + echo "AGENTIC_SPECS_ROOT=/opt/ligbox-ops-platform/specs" >> .env + else + echo "ERRO: .env não encontrado — copie manualmente" >&2 + exit 1 + fi +fi + +docker compose -f docker-compose.agentic-staging.yml up -d --build + +sleep 8 +echo "==> Health staging" +curl -sf "http://10.10.10.122:8180/api/health" | head -c 200; echo +curl -sf "http://10.10.10.122:8180/api/v1/agents/health" | head -c 300; echo + +TOKEN=$(grep OPS_INTERNAL_TOKEN .env | cut -d= -f2) +curl -sf -X POST "http://10.10.10.122:8180/api/v1/agents/internal/tick" \ + -H "X-Ops-Internal-Token: $TOKEN" | head -c 400; echo + +echo "==> Staging UI: http://10.10.10.122:8192" +echo "==> Staging API: http://10.10.10.122:8180" diff --git a/projects/ops-desk/api/app/agents/checks.py b/projects/ops-desk/api/app/agents/checks.py index 43a4be5..356f969 100644 --- a/projects/ops-desk/api/app/agents/checks.py +++ b/projects/ops-desk/api/app/agents/checks.py @@ -1,6 +1,10 @@ """T0/T1 checks — Spec 029.""" from __future__ import annotations -import os, sqlite3, time + +import os +import sqlite3 +import time + import httpx DESK = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br") @@ -11,85 +15,252 @@ PFS_USER = os.getenv("PFSENSE_API_USER", "api_cursor") PFS_PASS = os.getenv("PFSENSE_API_PASSWORD", "805353") PVE = os.getenv("PVE_API_URL", "https://10.10.10.2:8006/api2/json") PVE_USER = os.getenv("PVE_USER", "root@pam") -PVE_PASS = os.getenv("PVE_PASSWORD", "@betinplace") +PVE_PASS = os.getenv("PVE_PASSWORD", "") PVE_NODE = os.getenv("PVE_NODE", "big1") VMIDS = [int(x) for x in os.getenv("AGENTIC_CRITICAL_VMIDS", "112,122,123,104").split(",") if x.strip()] OLLAMA = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/") +VM123_IP = os.getenv("VM123_IP", "10.10.10.123") +OPENPANEL_BRIDGE = os.getenv("OPENPANEL_BRIDGE_URL", f"http://{VM123_IP}:18087").rstrip("/") + def _http(url, *, auth=None, max_ms=2500): t0 = time.perf_counter() try: with httpx.Client(timeout=15, verify=False, follow_redirects=True) as c: r = c.get(url, auth=auth) - ms = int((time.perf_counter()-t0)*1000) - return {"ok": r.status_code==200 and ms<=max_ms, "status_code": r.status_code, "latency_ms": ms, "url": url} + ms = int((time.perf_counter() - t0) * 1000) + return {"ok": r.status_code == 200 and ms <= max_ms, "status_code": r.status_code, "latency_ms": ms, "url": url} except Exception as e: return {"ok": False, "error": str(e), "url": url} + def check_desk_api_health(): - r = _http(f"{DESK}/api/health") - return [] if r["ok"] else [{"severity":"high","category":"api","title":"Desk API health falhou","detail_md":str(r),"evidence":r,"human_action":"docker-compose logs api VM122"}] + r = _http(f"{DESK}/api/health", max_ms=4000) + return [] if r["ok"] else [ + { + "severity": "high", + "category": "api", + "title": "Desk API health falhou", + "detail_md": str(r), + "evidence": r, + "human_action": "Verificar docker-compose api VM122", + } + ] + def check_vm112_health(): out = [] r1 = _http(f"{VM112}/api/onboarding/health") - if not r1["ok"]: out.append({"severity":"high","category":"api","title":"VM112 API down","detail_md":str(r1),"evidence":r1,"human_action":"systemctl ligbox-wizard VM112"}) + if not r1["ok"]: + out.append( + { + "severity": "high", + "category": "api", + "title": "VM112 API down", + "detail_md": str(r1), + "evidence": r1, + "human_action": "systemctl ligbox-wizard VM112", + } + ) r2 = _http(WIZARD, max_ms=4000) - if not r2["ok"]: out.append({"severity":"warn","category":"api","title":"Portal /onboard falhou","detail_md":str(r2),"evidence":r2,"human_action":"Traefik + VM112"}) + if not r2["ok"]: + out.append( + { + "severity": "warn", + "category": "api", + "title": "Portal /onboard falhou", + "detail_md": str(r2), + "evidence": r2, + "human_action": "Traefik CT114 + VM112", + } + ) return out + def check_pfsense_api(): r = _http(PFS_URL, auth=(PFS_USER, PFS_PASS), max_ms=4000) - return [] if r["ok"] else [{"severity":"warn","category":"infra","title":"pfSense API falhou","detail_md":str(r),"evidence":r,"human_action":"firewall.itecnologys.com"}] + return [] if r["ok"] else [ + { + "severity": "warn", + "category": "infra", + "title": "pfSense API falhou", + "detail_md": str(r), + "evidence": r, + "human_action": "Validar firewall.itecnologys.com via Traefik", + } + ] + def check_funnel_stuck(conn, max_stuck=5): try: - c = conn.execute("SELECT COUNT(*) n FROM tickets WHERE status IN ('open','assisting','escalated') AND (subject LIKE '%onboarding%' OR payload LIKE '%onboarding%') AND datetime(created_at) bool: try: with httpx.Client(timeout=3.0) as c: @@ -14,25 +18,70 @@ def ollama_available() -> bool: except Exception: return False -def advise_human_action(*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None) -> tuple[str, str]: + +def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> tuple[str, str]: + if not AGENTIC_LLM_ENABLED or not ollama_available(): + return ("", "t0") + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + try: + with httpx.Client(timeout=120.0) as c: + r = c.post( + f"{OLLAMA_BASE_URL}/api/chat", + json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": False}, + ) + if r.status_code == 200: + txt = (r.json().get("message") or {}).get("content", "").strip() + if txt: + return txt, AGENTIC_LLM_MODEL + except Exception: + pass + return ("", "t0-fallback") + + +def advise_human_action( + *, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None +) -> tuple[str, str]: prompt = ( "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n" - f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}" + f"Problema: {finding_title}\nDetalhe: {finding_detail}\n" + f"KB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}" + ) + txt, model = _chat(prompt) + if txt: + return txt, model + return (f"Investigar manualmente: {finding_title}", "t0") + + +def chat_context( + *, + question: str, + kb_snippets: list[str] | None = None, + findings_summary: str | None = None, + user_role: str = "technician", +) -> tuple[str, str]: + """T1 — resposta contextual para janela Desk / bot interno.""" + system = ( + "És o copiloto Agentic Ops da Ligbox (VM112 wizard, VM122 Desk, VM123 finance). " + "Responde em português BR, objectivo, com passos acionáveis. " + "Nunca inventes credenciais. Se não souberes, diz o que verificar." + ) + ctx = [] + if findings_summary: + ctx.append(f"Findings abertos:\n{findings_summary[:2000]}") + if kb_snippets: + ctx.append("KB:\n" + "\n---\n".join(kb_snippets[:6])[:4000]) + prompt = ( + f"Perfil utilizador: {user_role}\n" + f"Contexto ops:\n{chr(10).join(ctx) or 'N/A'}\n\n" + f"Pergunta: {question}" + ) + txt, model = _chat(prompt, system=system) + if txt: + return txt, model + return ( + "Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.", + "t0", ) - if not AGENTIC_LLM_ENABLED: - return (f"Investigar manualmente: {finding_title}", "t0") - if ollama_available(): - try: - with httpx.Client(timeout=90.0) as c: - r = c.post(f"{OLLAMA_BASE_URL}/api/chat", json={ - "model": AGENTIC_LLM_MODEL, - "messages": [{"role": "user", "content": prompt}], - "stream": False, - }) - if r.status_code == 200: - txt = (r.json().get("message") or {}).get("content", "").strip() - if txt: - return txt, AGENTIC_LLM_MODEL - except Exception: - pass - return (f"Rever logs e specs para: {finding_title}", "t0-fallback") diff --git a/projects/ops-desk/api/app/agents/registry.py b/projects/ops-desk/api/app/agents/registry.py index b4e0794..4feabcb 100644 --- a/projects/ops-desk/api/app/agents/registry.py +++ b/projects/ops-desk/api/app/agents/registry.py @@ -16,5 +16,7 @@ def load_registry() -> list[dict]: {"id": "funnel.stuck.onboarding", "title": "Funil travado", "severity_default": "warn"}, {"id": "integration.webhook.gap", "title": "Gap webhook VM112", "severity_default": "high"}, {"id": "proxmox.cluster", "title": "Proxmox VMs críticas", "severity_default": "critical"}, - {"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high"}, + {"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high", "agent_id": "sentinel"}, + {"id": "vm123.finance.stack", "title": "VM123 Finance Stack", "severity_default": "high", "agent_id": "sentinel"}, + {"id": "vm123.openpanel.bridge", "title": "OpenPanel Bridge VM123", "severity_default": "warn", "agent_id": "sentinel"}, ] diff --git a/projects/ops-desk/api/app/agents/routes.py b/projects/ops-desk/api/app/agents/routes.py index f7e868d..af71b55 100644 --- a/projects/ops-desk/api/app/agents/routes.py +++ b/projects/ops-desk/api/app/agents/routes.py @@ -1,63 +1,145 @@ """Agentic API — Spec 029.""" from __future__ import annotations + from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + from app import auth from app.agents import llm_client, runner, store router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) + def _db(): conn = auth.db() - try: yield conn - finally: conn.close() + try: + yield conn + finally: + conn.close() + def _ops_view(user): - if user.role not in ("super_admin","ops_lead","technician","noc","agentic_operator"): + if user.role not in ( + "super_admin", + "ops_lead", + "technician", + "noc", + "agentic_operator", + "developer", + "devops", + "security_analyst", + ): raise HTTPException(403, "insufficient permissions") + +class ChatRequest(BaseModel): + question: str = Field(..., min_length=2, max_length=4000) + include_findings: bool = True + + @router.get("/health") def agents_health(): - return {"status":"ok","tier":"t1" if llm_client.AGENTIC_LLM_ENABLED else "t0", - "ollama": llm_client.ollama_available(), "ollama_url": llm_client.OLLAMA_BASE_URL, - "model": llm_client.AGENTIC_LLM_MODEL} + return { + "status": "ok", + "tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0", + "ollama": llm_client.ollama_available(), + "ollama_url": llm_client.OLLAMA_BASE_URL, + "model": llm_client.AGENTIC_LLM_MODEL, + "embed_model": llm_client.AGENTIC_EMBED_MODEL, + } + @router.get("/scenarios") def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user); runner.sync_registry(conn); conn.commit() + _ops_view(user) + runner.sync_registry(conn) + conn.commit() return {"scenarios": store.list_scenarios(conn)} + @router.get("/findings") -def list_findings(user=Depends(auth.get_current_user), conn=Depends(_db), severity: str|None=None, limit: int=Query(50, ge=1, le=200), open_only: bool=True): +def list_findings( + user=Depends(auth.get_current_user), + conn=Depends(_db), + severity: str | None = None, + limit: int = Query(50, ge=1, le=200), + open_only: bool = True, +): _ops_view(user) return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)} + @router.post("/findings/{finding_id}/ack") def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): _ops_view(user) if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone(): raise HTTPException(404, "not found") now = datetime.now(timezone.utc).isoformat() - conn.execute("UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", (now, user.username, finding_id)) + conn.execute( + "UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", + (now, user.username, finding_id), + ) store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username}) conn.commit() return {"ok": True, "id": finding_id} + @router.get("/action-log") -def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int=Query(100, ge=1, le=500)): +def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)): _ops_view(user) return {"events": store.list_action_log(conn, limit=limit)} + @router.post("/runs/{scenario_id}") def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)): - if user.role not in ("super_admin","ops_lead"): raise HTTPException(403, "insufficient permissions") + if user.role not in ("super_admin", "ops_lead", "agentic_operator"): + raise HTTPException(403, "insufficient permissions") r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}") - conn.commit(); return r + conn.commit() + return r + + +@router.post("/chat") +def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): + """T1 context window — copiloto ops para utilizadores Desk.""" + _ops_view(user) + kb = store.search_kb(conn, body.question) + findings_summary = "" + if body.include_findings: + open_f = store.list_findings(conn, limit=8, open_only=True) + if open_f: + findings_summary = "\n".join( + f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f + ) + answer, model = llm_client.chat_context( + question=body.question, + kb_snippets=[k["snippet"] for k in kb], + findings_summary=findings_summary, + user_role=user.role, + ) + store.log_event( + conn, + event_type="chat.query", + message=body.question[:120], + agent_id="advisor", + payload={"user": user.username, "model": model}, + ) + conn.commit() + return {"answer": answer, "model": model, "kb_hits": len(kb)} + @router.post("/internal/tick") def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)): kb = runner.index_specs_kb(conn) result = runner.run_all_enabled(conn, trigger="cron") - store.log_event(conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", payload={"kb": kb, **result}) + store.log_event( + conn, + event_type="tick.complete", + message=f"kb={kb} runs={result['total']}", + agent_id="orchestrator", + payload={"kb": kb, **result}, + ) conn.commit() return {"ok": True, "kb_indexed": kb, **result} diff --git a/projects/ops-desk/api/app/agents/runner.py b/projects/ops-desk/api/app/agents/runner.py index 2d8d31f..ac24179 100644 --- a/projects/ops-desk/api/app/agents/runner.py +++ b/projects/ops-desk/api/app/agents/runner.py @@ -28,11 +28,14 @@ def index_specs_kb(conn): def run_scenario(conn, scenario_id, *, trigger="cron"): sc = store.get_scenario(conn, scenario_id) - if not sc: return {"ok": False, "error": "not found"} + if not sc: + return {"ok": False, "error": "not found"} + agent_id = (sc.get("config") or {}).get("agent_id") or sc.get("agent_id") or "sentinel" fn = checks.SCENARIO_RUNNERS.get(scenario_id) - if not fn: return {"ok": False, "error": "no runner"} + if not fn: + return {"ok": False, "error": "no runner"} run_id = store.create_run(conn, scenario_id, trigger) - store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id) + store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id, agent_id=agent_id) raw = fn(conn, ops_api_url=OPS_API, internal_token=TOKEN) fids = [] for f in raw: @@ -50,10 +53,10 @@ def run_scenario(conn, scenario_id, *, trigger="cron"): fids.append(fid) if f.get("severity") in ("high", "critical"): notify.notify_finding({**f, "suggested_human_action": human}) - store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}) + store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id) status = "ok" if not raw else "degraded" store.finish_run(conn, run_id, status=status, summary=f"{len(raw)} finding(s)" if raw else "healthy") - store.log_event(conn, event_type="run.finish", message=status, run_id=run_id) + store.log_event(conn, event_type="run.finish", message=status, run_id=run_id, agent_id=agent_id) return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids} def run_all_enabled(conn, trigger="cron"): diff --git a/projects/ops-desk/api/app/agents/scenarios/registry.yaml b/projects/ops-desk/api/app/agents/scenarios/registry.yaml index 11d16d4..97629a3 100644 --- a/projects/ops-desk/api/app/agents/scenarios/registry.yaml +++ b/projects/ops-desk/api/app/agents/scenarios/registry.yaml @@ -3,21 +3,36 @@ scenarios: - id: desk.api.health title: Desk VM122 API severity_default: high + agent_id: sentinel - id: wizard.vm112.bundle title: VM112 Wizard severity_default: high + agent_id: sentinel - id: pfsense.api.system title: pfSense API severity_default: warn + agent_id: sentinel - id: funnel.stuck.onboarding title: Funil travado severity_default: warn + agent_id: dispatcher - id: integration.webhook.gap title: Gap webhook VM112 severity_default: high + agent_id: sentinel - id: proxmox.cluster title: Proxmox VMs críticas severity_default: critical + agent_id: sentinel - id: ollama.vm123.health title: Ollama VM123 severity_default: high + agent_id: sentinel + - id: vm123.finance.stack + title: VM123 Finance Stack + severity_default: high + agent_id: sentinel + - id: vm123.openpanel.bridge + title: OpenPanel Bridge VM123 + severity_default: warn + agent_id: sentinel diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index 3f85a03..c64b6d9 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -28,6 +28,8 @@ 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.agents.routes import router as agents_router +from app.agents.store import init_agent_schema from app.collectors.base import run_audit from app.permissions import ( can_assign_ticket, @@ -117,7 +119,7 @@ ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.as TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"}) -app = FastAPI(title="Ligbox Ops Platform API", version="0.9.0-desk-assist") +app = FastAPI(title="Ligbox Ops Platform API", version="0.9.7-spec029-agentic") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.include_router(auth_router) app.include_router(registration_router) @@ -133,6 +135,7 @@ app.include_router(migration_router) app.include_router(billing_router) app.include_router(infra_stack_router) app.include_router(vm123_router) +app.include_router(agents_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" @@ -185,6 +188,7 @@ def init_db(): init_purge_jobs_schema(conn) init_purge_auth_schema(conn) + init_agent_schema(conn) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=30000") conn.commit() diff --git a/projects/ops-desk/api/app/modules/registry.py b/projects/ops-desk/api/app/modules/registry.py index e49d1e8..5092bed 100644 --- a/projects/ops-desk/api/app/modules/registry.py +++ b/projects/ops-desk/api/app/modules/registry.py @@ -47,6 +47,13 @@ MODULES: tuple[ModuleDef, ...] = ( description="Painel visual SOC VM112→VM122.", nav_views=("infra2",), ), + ModuleDef( + id="agentic-ops", + label="Agentic Ops", + description="Vigilância 24/7, findings, advisor IA e contexto ops (Spec 029).", + nav_views=("agentic-ops",), + default_enabled=True, + ), ModuleDef( id="funnel-timing", label="Relógio por fase", @@ -135,6 +142,29 @@ MODULES: tuple[ModuleDef, ...] = ( MODULE_BY_ID = {m.id: m for m in MODULES} +# Spec 027 + 029 — módulos ON por defeito na activação +ROLE_MODULE_DEFAULTS: dict[str, frozenset[str]] = { + "sales_admin": frozenset( + {"core", "leads", "funnel-timing", "overview-home", "billing-recurrence", "tenants"} + ), + "sales_support": frozenset({"core", "leads", "funnel-timing", "overview-home", "tenants"}), + "finance": frozenset({"core", "overview-home", "billing-recurrence", "events"}), + "marketing": frozenset({"core", "leads", "funnel-timing", "overview-home"}), + "seo": frozenset({"core", "funnel-timing", "overview-home", "leads"}), + "developer": frozenset({"core", "events", "infra", "overview", "agentic-ops"}), + "devops": frozenset({"core", "infra", "infra2-soc", "overview-home", "events", "agentic-ops"}), + "security_analyst": frozenset({"core", "infra2-soc", "wazuh-soc", "events", "agentic-ops"}), + "content_editor": frozenset({"core"}), + "agentic_operator": frozenset({"core", "overview", "events", "infra2-soc", "agentic-ops"}), +} + + +def role_module_defaults(role: str) -> frozenset[str] | None: + """None = roles ops legacy (003) — respeitam só toggles globais.""" + if role in ("super_admin", "ops_lead", "technician", "noc"): + return None + return ROLE_MODULE_DEFAULTS.get(role, frozenset({"core"})) + def all_module_ids() -> list[str]: return [m.id for m in MODULES] diff --git a/projects/ops-desk/api/requirements.txt b/projects/ops-desk/api/requirements.txt index e18a39c..28e32ef 100644 --- a/projects/ops-desk/api/requirements.txt +++ b/projects/ops-desk/api/requirements.txt @@ -6,3 +6,4 @@ python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.2.1 pyotp==2.9.0 +pyyaml==6.0.2 diff --git a/projects/ops-desk/api/tests/test_agents_029.py b/projects/ops-desk/api/tests/test_agents_029.py new file mode 100644 index 0000000..2ba3104 --- /dev/null +++ b/projects/ops-desk/api/tests/test_agents_029.py @@ -0,0 +1,26 @@ +"""Tests Agentic Ops — Spec 029.""" +from __future__ import annotations + +import sqlite3 + +from app.agents import checks, registry, store + + +def test_registry_has_vm123_scenarios(): + scenarios = registry.load_registry() + ids = {s["id"] for s in scenarios} + assert "ollama.vm123.health" in ids + assert "vm123.finance.stack" in ids + + +def test_agent_schema_init(): + conn = sqlite3.connect(":memory:") + store.init_agent_schema(conn) + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "agent_findings" in tables + assert "agent_scenarios" in tables + + +def test_desk_health_check_returns_list(): + result = checks.check_desk_api_health() + assert isinstance(result, list) diff --git a/projects/ops-desk/docker-compose.agentic-staging.yml b/projects/ops-desk/docker-compose.agentic-staging.yml new file mode 100644 index 0000000..69aaac8 --- /dev/null +++ b/projects/ops-desk/docker-compose.agentic-staging.yml @@ -0,0 +1,46 @@ +# Staging Agentic Ops — isolado da produção VM122 +# Portas: API 8180, Frontend 8192, Redis interno +# Dados: /var/lib/ligbox-ops-platform-staging (separado) + +version: "3.8" +services: + redis-staging: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --maxmemory 64mb --maxmemory-policy allkeys-lru + networks: [agentic-staging] + api-staging: + build: ./api + restart: unless-stopped + env_file: .env + environment: + SQLITE_PATH: /data/ops-staging.db + REDIS_URL: redis://redis-staging:6379/0 + OPS_API_URL: http://api-staging:8080 + volumes: + - /var/lib/ligbox-ops-platform-staging:/data + - ./specs:/opt/ligbox-ops-platform/specs:ro + ports: + - "10.10.10.122:8180:8080" + depends_on: [redis-staging] + networks: [agentic-staging] + worker-staging: + build: ./worker + restart: unless-stopped + env_file: .env + environment: + OPS_API_URL: http://api-staging:8080 + REDIS_URL: redis://redis-staging:6379/0 + AGENTIC_INTERVAL_SEC: "300" + depends_on: [redis-staging, api-staging] + networks: [agentic-staging] + frontend-staging: + build: ./frontend + restart: unless-stopped + ports: + - "10.10.10.122:8192:80" + depends_on: [api-staging] + networks: [agentic-staging] +networks: + agentic-staging: + driver: bridge diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js index 0328b12..321c458 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.js +++ b/projects/ops-desk/frontend/assets/agentic-ops.js @@ -1,13 +1,22 @@ (function () { const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>'); + async function api(path, opts = {}) { - const h = { ...(opts.headers || {}) }; + const h = { ...(opts.headers || {}), 'Content-Type': 'application/json' }; const t = window.DeskAuth?.getToken?.(); if (t) h.Authorization = `Bearer ${t}`; const r = await fetch(`/api/v1/agents${path}`, { ...opts, headers: h }); - if (!r.ok) throw new Error(`${r.status}`); + if (!r.ok) { + const err = await r.text(); + throw new Error(`${r.status} ${err.slice(0, 200)}`); + } return r.json(); } + + async function sendChat(question) { + return api('/chat', { method: 'POST', body: JSON.stringify({ question, include_findings: true }) }); + } + async function renderAgenticOps() { const el = document.getElementById('agentic-ops-content'); if (!el) return; @@ -17,16 +26,63 @@ api('/health'), api('/scenarios'), api('/findings?limit=30'), api('/action-log?limit=40'), ]); const tier = health.tier === 't1' ? 'T1 LLM' : 'T0'; - const ollama = health.ollama ? 'Ollama OK' : 'Ollama offline'; - const sRows = (scenarios.scenarios || []).map(s => `${esc(s.id)}${esc(s.title)}${esc(s.last_run_status||'—')}${esc(s.last_run_at||'—')}`).join(''); - const fRows = (findings.findings || []).map(f => `

${esc(f.title)} ${esc(f.severity)}

${esc(f.created_at)}

${f.suggested_human_action?`

Acção: ${esc(f.suggested_human_action)}

`:''}
`).join('') || '

Sem findings abertos.

'; - const lRows = (log.events || []).map(e => `${esc(e.ts)}${esc(e.event_type)}${esc(e.message)}`).join(''); - el.innerHTML = `

Agentic Ops

Spec 029 · ${tier} ${ollama}

Cenários

${sRows}
IDTítuloÚltimoQuando

Findings

${fRows}

Audit log

${lRows}
QuandoEventoMensagem
`; + const ollama = health.ollama + ? `Ollama OK · ${esc(health.model || '')}` + : 'Ollama offline — modo T0'; + const sRows = (scenarios.scenarios || []).map(s => + `${esc(s.id)}${esc(s.title)}${esc(s.last_run_status||'—')}${esc(s.last_run_at||'—')}` + ).join(''); + const fRows = (findings.findings || []).map(f => + `

${esc(f.title)} ${esc(f.severity)}

` + + `

${esc(f.created_at)}

` + + (f.suggested_human_action ? `

Acção: ${esc(f.suggested_human_action)}

` : '') + + `
` + ).join('') || '

Sem findings abertos.

'; + const lRows = (log.events || []).map(e => + `${esc(e.ts)}${esc(e.event_type)}${esc(e.message)}` + ).join(''); + el.innerHTML = ` +
+

Agentic Ops

Spec 029 · ${tier} ${ollama}

+ +
+
+

Cenários

+ ${sRows}
IDTítuloÚltimoQuando
+
+

Findings

${fRows}
+
+
+

Copiloto Ops (T1)

+

Pergunte sobre infra, VM123, findings ou procedimentos — resposta contextual pt-BR.

+
+ + +
+ +
+

Audit log

+ ${lRows}
QuandoEventoMensagem
+
`; el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps); el.querySelectorAll('[data-ack]').forEach(btn => btn.addEventListener('click', async () => { await api(`/findings/${btn.dataset.ack}/ack`, { method: 'POST' }); await renderAgenticOps(); })); + el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => { + const input = el.querySelector('#agentic-chat-input'); + const out = el.querySelector('#agentic-chat-answer'); + const q = (input?.value || '').trim(); + if (!q) return; + out.hidden = false; + out.innerHTML = '

A pensar…

'; + try { + const res = await sendChat(q); + out.innerHTML = `

Resposta (${esc(res.model)})

${esc(res.answer)}

`; + } catch (err) { + out.innerHTML = `

${esc(err.message)}

`; + } + }); } catch (err) { el.innerHTML = `

Erro: ${esc(err.message)}

`; } diff --git a/projects/ops-desk/frontend/assets/app.js b/projects/ops-desk/frontend/assets/app.js index 3f4fb09..f1d95a1 100644 --- a/projects/ops-desk/frontend/assets/app.js +++ b/projects/ops-desk/frontend/assets/app.js @@ -76,6 +76,7 @@ const views = { 'email-migration': document.getElementById('view-email-migration'), infra: document.getElementById('view-infra'), infra2: document.getElementById('view-infra2'), + 'agentic-ops': document.getElementById('view-agentic-ops'), messages: document.getElementById('view-messages'), admin: document.getElementById('view-admin'), account: document.getElementById('view-account'), @@ -210,6 +211,7 @@ function setView(name) { tenants: 'Tenants', infra: 'INFRA CODE', infra2: 'SOC — Infra 2', + 'agentic-ops': 'Agentic Ops', messages: 'Mensagens — pedidos de cadastro', admin: 'Administradores', account: 'Minha conta', @@ -225,6 +227,7 @@ function setView(name) { tenants: 'Operações Ligbox — onboarding, tickets e monitoramento', infra: 'Infrastructure as Code — stack VMs 112, 114, 122, 123, 130', infra2: 'Centro de operações — monitoramento visual VM112 → VM122 em tempo quase real', + 'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)', messages: 'Operações Ligbox — onboarding, tickets e monitoramento', admin: 'Operações Ligbox — onboarding, tickets e monitoramento', account: 'Operações Ligbox — onboarding, tickets e monitoramento', @@ -4221,6 +4224,7 @@ async function refresh(options = {}) { if (state.view === 'tenants') await renderTenants(); if (state.view === 'infra') await renderInfra(); if (state.view === 'infra2') await renderInfra2(); + if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps(); if (state.view === 'messages') await renderMessages(); if (state.view === 'admin') await renderAdmin(); if (state.view === 'modules') await renderModules(); diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index f4b7c12..7427ec0 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -225,6 +225,10 @@ Infra 2 SOC +