diff --git a/projects/ops-desk/api/app/agent_bindings.py b/projects/ops-desk/api/app/agent_bindings.py new file mode 100644 index 0000000..5191a44 --- /dev/null +++ b/projects/ops-desk/api/app/agent_bindings.py @@ -0,0 +1,358 @@ +"""Persistência e enforcement — atribuições Agentics × função (Spec 027 UI-C).""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timezone +from typing import Any + +RELATIONS = ("ui", "focus", "approve") +CAP_IDS = ("use_ui", "trigger_runs", "approve_runbooks", "configure_models") + +LOCKED_AGENT_BINDINGS: frozenset[tuple[str, str, str]] = frozenset({ + ("A7", "agentic_operator", "approve"), + ("A7", "super_admin", "approve"), +}) + +AGENT_IDS = ("A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7") + + +def _spec(): + from app.rbac_matrix import AGENTIC_GOVERNANCE, AGENT_ROLE_MAP, ROLE_COLUMNS + + return AGENTIC_GOVERNANCE, AGENT_ROLE_MAP, ROLE_COLUMNS + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _default_agent_enabled(agent_id: str, role_id: str, relation: str) -> bool: + _, AGENT_ROLE_MAP, _ = _spec() + from app.rbac_matrix import AGENTIC_GOVERNANCE + + mapping = AGENT_ROLE_MAP.get(agent_id, {}) + if relation == "approve": + return role_id in mapping.get("approvers", []) + if relation == "focus": + return role_id in mapping.get("operators", []) + if relation == "ui": + return role_id in AGENTIC_GOVERNANCE["use_ui"] + return False + + +def _default_cap_enabled(cap_id: str, role_id: str) -> bool: + AGENTIC_GOVERNANCE, _, _ = _spec() + roles = AGENTIC_GOVERNANCE.get(cap_id) + if isinstance(roles, list): + return role_id in roles + return False + + +def _is_locked(agent_id: str, role_id: str, relation: str) -> bool: + return (agent_id, role_id, relation) in LOCKED_AGENT_BINDINGS + + +def init_schema(conn: sqlite3.Connection) -> None: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS agent_role_bindings ( + agent_id TEXT NOT NULL, + role_id TEXT NOT NULL, + relation TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + locked INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + updated_by TEXT, + PRIMARY KEY (agent_id, role_id, relation) + ); + CREATE TABLE IF NOT EXISTS agent_governance_caps ( + cap_id TEXT NOT NULL, + role_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + locked INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + updated_by TEXT, + PRIMARY KEY (cap_id, role_id) + ); + CREATE TABLE IF NOT EXISTS rbac_agent_audit ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_rbac_agent_audit_ts ON rbac_agent_audit(ts DESC); + """ + ) + _seed_defaults(conn) + + +def _seed_defaults(conn: sqlite3.Connection) -> None: + _, _, ROLE_COLUMNS = _spec() + now = _now() + if conn.execute("SELECT COUNT(*) c FROM agent_role_bindings").fetchone()["c"] == 0: + rows = [] + for agent_id in AGENT_IDS: + for role_id in ROLE_COLUMNS: + for relation in RELATIONS: + enabled = 1 if _default_agent_enabled(agent_id, role_id, relation) else 0 + locked = 1 if _is_locked(agent_id, role_id, relation) else 0 + rows.append((agent_id, role_id, relation, enabled, locked, now, "system")) + conn.executemany( + """INSERT INTO agent_role_bindings + (agent_id, role_id, relation, enabled, locked, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + rows, + ) + if conn.execute("SELECT COUNT(*) c FROM agent_governance_caps").fetchone()["c"] == 0: + cap_rows = [] + for cap_id in CAP_IDS: + for role_id in ROLE_COLUMNS: + enabled = 1 if _default_cap_enabled(cap_id, role_id) else 0 + cap_rows.append((cap_id, role_id, enabled, 0, now, "system")) + conn.executemany( + """INSERT INTO agent_governance_caps + (cap_id, role_id, enabled, locked, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?)""", + cap_rows, + ) + + +def audit_log( + conn: sqlite3.Connection, + *, + username: str, + action: str, + payload: dict[str, Any], +) -> None: + conn.execute( + "INSERT INTO rbac_agent_audit (ts, username, action, payload_json) VALUES (?, ?, ?, ?)", + (_now(), username, action, json.dumps(payload)), + ) + + +def list_audit(conn: sqlite3.Connection, *, limit: int = 50) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT id, ts, username, action, payload_json FROM rbac_agent_audit ORDER BY id DESC LIMIT ?", + (limit,), + ).fetchall() + out = [] + for r in rows: + item = dict(r) + try: + item["payload"] = json.loads(item.pop("payload_json") or "{}") + except json.JSONDecodeError: + item["payload"] = {} + out.append(item) + return out + + +def _row_enabled(row: sqlite3.Row | None, default: bool = False) -> bool: + if row is None: + return default + return bool(row["enabled"]) + + +def agent_relations(conn: sqlite3.Connection, agent_id: str, role_id: str) -> list[str]: + """Relações activas função ↔ agente.""" + if role_id == "super_admin": + return list(RELATIONS) + rels: list[str] = [] + for relation in RELATIONS: + row = conn.execute( + """SELECT enabled FROM agent_role_bindings + WHERE agent_id=? AND role_id=? AND relation=?""", + (agent_id, role_id, relation), + ).fetchone() + if _row_enabled(row, _default_agent_enabled(agent_id, role_id, relation)): + rels.append(relation) + return rels + + +def cap_enabled(conn: sqlite3.Connection, cap_id: str, role_id: str) -> bool: + if role_id == "super_admin": + return True + row = conn.execute( + "SELECT enabled FROM agent_governance_caps WHERE cap_id=? AND role_id=?", + (cap_id, role_id), + ).fetchone() + return _row_enabled(row, _default_cap_enabled(cap_id, role_id)) + + +def can_use_agentics_ui(conn: sqlite3.Connection, role_id: str) -> bool: + if role_id == "super_admin": + return True + return cap_enabled(conn, "use_ui", role_id) + + +def can_chat_agent(conn: sqlite3.Connection, role_id: str, agent_id: str) -> bool: + if role_id == "super_admin": + return True + rels = agent_relations(conn, agent_id, role_id) + return bool(set(rels) & {"ui", "focus", "approve"}) + + +def can_approve_agent(conn: sqlite3.Connection, role_id: str, agent_id: str) -> bool: + if role_id == "super_admin": + return True + return "approve" in agent_relations(conn, agent_id, role_id) + + +def can_trigger_runs(conn: sqlite3.Connection, role_id: str) -> bool: + if role_id == "super_admin": + return True + return cap_enabled(conn, "trigger_runs", role_id) + + +def can_approve_runbooks(conn: sqlite3.Connection, role_id: str) -> bool: + if role_id == "super_admin": + return True + return cap_enabled(conn, "approve_runbooks", role_id) + + +def load_bindings_matrix(conn: sqlite3.Connection) -> dict[str, Any]: + """Estado completo para UI / export.""" + agent_rows = conn.execute( + "SELECT agent_id, role_id, relation, enabled, locked FROM agent_role_bindings" + ).fetchall() + cap_rows = conn.execute( + "SELECT cap_id, role_id, enabled, locked FROM agent_governance_caps" + ).fetchall() + + agents: dict[str, dict[str, dict[str, dict[str, Any]]]] = {} + for r in agent_rows: + agents.setdefault(r["agent_id"], {}).setdefault(r["role_id"], {})[r["relation"]] = { + "enabled": bool(r["enabled"]), + "locked": bool(r["locked"]), + } + + caps: dict[str, dict[str, dict[str, Any]]] = {} + for r in cap_rows: + caps.setdefault(r["cap_id"], {})[r["role_id"]] = { + "enabled": bool(r["enabled"]), + "locked": bool(r["locked"]), + } + + return {"agents": agents, "caps": caps} + + +def role_agentic_caps(conn: sqlite3.Connection, role_id: str) -> list[dict[str, str]]: + from app.rbac_matrix import AGENTIC_GOVERNANCE + + caps = [] + for cap_id in CAP_IDS: + if cap_enabled(conn, cap_id, role_id): + caps.append({ + "id": cap_id, + "label": AGENTIC_GOVERNANCE["labels"].get(cap_id, cap_id), + }) + return caps + + +def set_agent_binding( + conn: sqlite3.Connection, + *, + agent_id: str, + role_id: str, + relation: str, + enabled: bool, + username: str, +) -> dict[str, Any]: + _, _, ROLE_COLUMNS = _spec() + if agent_id not in AGENT_IDS: + raise ValueError(f"agente inválido: {agent_id}") + if role_id not in ROLE_COLUMNS: + raise ValueError(f"função inválida: {role_id}") + if relation not in RELATIONS: + raise ValueError(f"relação inválida: {relation}") + + row = conn.execute( + """SELECT enabled, locked FROM agent_role_bindings + WHERE agent_id=? AND role_id=? AND relation=?""", + (agent_id, role_id, relation), + ).fetchone() + locked = bool(row["locked"]) if row else _is_locked(agent_id, role_id, relation) + if locked and not enabled: + raise ValueError("binding bloqueado pela Spec 027 (obrigatório)") + + now = _now() + if row: + conn.execute( + """UPDATE agent_role_bindings SET enabled=?, updated_at=?, updated_by=? + WHERE agent_id=? AND role_id=? AND relation=?""", + (1 if enabled else 0, now, username, agent_id, role_id, relation), + ) + else: + conn.execute( + """INSERT INTO agent_role_bindings + (agent_id, role_id, relation, enabled, locked, updated_at, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (agent_id, role_id, relation, 1 if enabled else 0, 1 if locked else 0, now, username), + ) + + audit_log( + conn, + username=username, + action="agent_binding.update", + payload={ + "agent_id": agent_id, + "role_id": role_id, + "relation": relation, + "enabled": enabled, + }, + ) + return { + "agent_id": agent_id, + "role_id": role_id, + "relation": relation, + "enabled": enabled, + "locked": locked, + } + + +def set_governance_cap( + conn: sqlite3.Connection, + *, + cap_id: str, + role_id: str, + enabled: bool, + username: str, +) -> dict[str, Any]: + _, _, ROLE_COLUMNS = _spec() + if cap_id not in CAP_IDS: + raise ValueError(f"capacidade inválida: {cap_id}") + if role_id not in ROLE_COLUMNS: + raise ValueError(f"função inválida: {role_id}") + + row = conn.execute( + "SELECT enabled, locked FROM agent_governance_caps WHERE cap_id=? AND role_id=?", + (cap_id, role_id), + ).fetchone() + locked = bool(row["locked"]) if row else False + if locked and not enabled: + raise ValueError("capacidade bloqueada") + + now = _now() + if row: + conn.execute( + """UPDATE agent_governance_caps SET enabled=?, updated_at=?, updated_by=? + WHERE cap_id=? AND role_id=?""", + (1 if enabled else 0, now, username, cap_id, role_id), + ) + else: + conn.execute( + """INSERT INTO agent_governance_caps + (cap_id, role_id, enabled, locked, updated_at, updated_by) + VALUES (?, ?, ?, 0, ?, ?)""", + (cap_id, role_id, 1 if enabled else 0, now, username), + ) + + audit_log( + conn, + username=username, + action="agent_cap.update", + payload={"cap_id": cap_id, "role_id": role_id, "enabled": enabled}, + ) + return {"cap_id": cap_id, "role_id": role_id, "enabled": enabled, "locked": locked} diff --git a/projects/ops-desk/api/app/agents/chat_context_builder.py b/projects/ops-desk/api/app/agents/chat_context_builder.py new file mode 100644 index 0000000..f4f6444 --- /dev/null +++ b/projects/ops-desk/api/app/agents/chat_context_builder.py @@ -0,0 +1,240 @@ +"""Contexto operacional rico para chat investigativo — Spec 030+.""" + +from __future__ import annotations + +import sqlite3 + +from app.agents.catalog import AGENT_CATALOG +from app.agents import store + + +AGENT_INVESTIGATION_LENS: dict[str, str] = { + "A0": "Visão global: correlacionar incidentes, delegar aos agentes certos, priorizar por severidade.", + "A1": "Investigar: VM112 wizard/Carbonio, Proxmox cluster, CPU/RAM, containers down, restarts.", + "A2": "Investigar: DNS Cloudflare, Traefik CT114, certificados LE, SNI, pfSense API/regras.", + "A3": "Investigar: SPF/DKIM/DMARC, entregabilidade mail, registos DNS por domínio tenant.", + "A4": "Investigar: filas mail, amavis/clamav, spam, quarentena, bloqueios de envio.", + "A5": "Investigar: alertas Wazuh VM104, correlação SIEM, timeline segurança, runbooks R0/R1.", + "A6": "Investigar: tickets Desk, onboarding stuck, findings abertos, contexto cliente, runbooks KB.", + "A7": "Investigar: runbooks aprovados pós-incidente, remediação R0–R3, aprovações pendentes.", + "sentinel": "Investigar: health checks T0 (desk, wizard, pfSense, proxmox, ollama, VM123 stack).", +} + + +def _fmt_incident(inc: dict) -> str: + action = (inc.get("suggested_human_action") or "Investigar manualmente.")[:200] + return ( + f"- [{inc.get('severity', '?').upper()}] {inc.get('title')} " + f"(cenário `{inc.get('scenario_id')}`, agente {inc.get('agent_name', inc.get('primary_agent'))}, " + f"visto {inc.get('occurrence_count', 1)}×, última vez {inc.get('last_seen_at', '?')})\n" + f" → Acção sugerida: {action}" + ) + + +def build_ops_context( + conn: sqlite3.Connection, + question: str, + target_agent: str, + *, + include_findings: bool = True, + kb_snippets: list[str] | None = None, +) -> str: + """Monta dossiê operacional para respostas investigativas.""" + sections: list[str] = [] + ov = store.get_overview(conn) + open_inc = ov.get("incidents_open") or {} + + sections.append( + "### Estado do ambiente (dados reais do Agentic Ops)\n" + f"- Provider LLM: {ov.get('provider')} / {ov.get('model')}\n" + f"- Último tick worker: {ov.get('last_tick_at') or 'desconhecido'} — status `{ov.get('last_tick_status', '?')}`\n" + f"- Cenários vigilância: **{ov.get('scenarios_ok', 0)}/{ov.get('scenarios_total', 0)} OK**\n" + f"- Incidentes abertos: {open_inc.get('critical', 0)} crít · " + f"{open_inc.get('high', 0)} alto · {open_inc.get('warn', 0)} aviso" + ) + + lens = AGENT_INVESTIGATION_LENS.get(target_agent, AGENT_INVESTIGATION_LENS["A6"]) + profile = AGENT_CATALOG.get(target_agent, AGENT_CATALOG["A6"]) + sections.append( + f"### Lente investigativa do agente {profile.name} ({target_agent})\n{lens}\n" + f"Competências: {', '.join(profile.reads[:4])}" + ) + + incidents = store.list_incidents(conn, status="open", limit=15) + if target_agent not in ("A0", "A6", "ALL"): + agent_first = [i for i in incidents if i.get("primary_agent") == target_agent] + other = [i for i in incidents if i.get("primary_agent") != target_agent][:5] + incidents = agent_first + other + if incidents: + sections.append( + "### Incidentes activos (deduplicados por cenário)\n" + + "\n".join(_fmt_incident(i) for i in incidents[:8]) + ) + else: + sections.append("### Incidentes activos\nNenhum incidente aberto no momento.") + + scenarios = store.list_scenarios(conn) + failing = [s for s in scenarios if s.get("last_run_status") not in (None, "ok")] + if failing: + fail_lines = [] + for s in failing[:8]: + fail_lines.append( + f"- `{s['id']}` — {s.get('title', s['id'])}: status **{s.get('last_run_status')}** " + f"(último run {s.get('last_run_at', '?')})" + ) + sections.append("### Cenários com falha recente\n" + "\n".join(fail_lines)) + + if include_findings: + findings = store.list_findings(conn, limit=10, open_only=True) + if findings: + flines = [] + for f in findings[:8]: + detail = (f.get("detail_md") or f.get("title") or "")[:180] + flines.append( + f"- [{f.get('severity')}] **{f.get('title')}** — {detail}\n" + f" → Humano: {(f.get('suggested_human_action') or 'verificar')[:160]}" + ) + sections.append("### Findings T0/T1 abertos\n" + "\n".join(flines)) + + if kb_snippets: + sections.append( + "### Base de conhecimento (RAG — specs/runbooks)\n" + + "\n---\n".join(kb_snippets[:6])[:4500] + ) + + sections.append( + "### Infra de referência Ligbox\n" + "- Proxmox host · pfSense WAN/LAN · VM112 wizard/onboard · VM122 Desk/API · VM123 finance/Ollama\n" + "- API pfSense via Traefik: `firewall.itecnologys.com` · Desk: `desk.ligbox.com.br`" + ) + + return "\n\n".join(sections) + + +RESPONSE_FORMAT_INSTRUCTION = """ +## Formato OBRIGATÓRIO da resposta (markdown) + +Estruture SEMPRE assim — respostas planas ou genéricas são proibidas: + +### 🔍 Diagnóstico +Sintetize o que os **dados do contexto** indicam sobre a pergunta. Cite incidentes/findings/cenários relevantes por nome. + +### 🧪 Hipóteses (ordem de probabilidade) +1. **Hipótese mais provável** — porquê, o que corrobora +2. **Hipótese alternativa** — o que verificar para confirmar/descartar +3. (opcional) terceira hipótese se aplicável + +### ✅ Plano de acção proposto +| # | Prioridade | Acção concreta | Onde / comando | +|---|------------|----------------|----------------| +| 1 | Alta/Média/Baixa | passo executável | `comando` ou painel/link | +| 2 | ... | ... | ... | + +Inclua **comandos shell** ou **URLs/painéis** quando souber (sem inventar credenciais). + +### ⚠️ Riscos e impacto +- O que pode piorar se ignorarmos +- Dependências (ex.: reiniciar X afecta Y) + +### ▶️ Próximo passo imediato +Uma frase directa: **faça isto agora** — a acção única de maior valor. + +--- +Regras: português BR · baseie-se no contexto injectado · se faltar dado, diga **exactamente** o que o operador deve correr/verificar · não invente estados de serviços. +""" + + +def _question_relevance(question: str, *texts: str) -> int: + q = question.lower() + score = 0 + for t in texts: + if not t: + continue + tl = str(t).lower() + for word in q.split(): + if len(word) > 3 and word in tl: + score += 2 + if tl in q or q in tl: + score += 3 + return score + + +def build_suggested_actions( + conn: sqlite3.Connection, + question: str, + target_agent: str, + *, + limit: int = 6, +) -> list[dict]: + """Acções executáveis no Desk — checks T0 e ack de incidentes.""" + actions: list[dict] = [] + seen_scenarios: set[str] = set() + seen_incidents: set[int] = set() + + def add_run(scenario_id: str, label: str, severity: str = "info") -> None: + if scenario_id in seen_scenarios or len(actions) >= limit: + return + if not store.get_scenario(conn, scenario_id): + return + seen_scenarios.add(scenario_id) + actions.append({ + "type": "run_scenario", + "scenario_id": scenario_id, + "label": label, + "severity": severity, + }) + + def add_ack(incident_id: int, label: str, severity: str, scenario_id: str) -> None: + if incident_id in seen_incidents or len(actions) >= limit: + return + seen_incidents.add(incident_id) + actions.append({ + "type": "ack_incident", + "incident_id": incident_id, + "scenario_id": scenario_id, + "label": label, + "severity": severity, + }) + + incidents = store.list_incidents(conn, status="open", limit=15) + ranked_inc = sorted( + incidents, + key=lambda i: ( + -{"critical": 4, "high": 3, "warn": 2, "info": 1}.get(i.get("severity", "info"), 0), + -_question_relevance(question, i.get("title", ""), i.get("scenario_id", "")), + ), + ) + for inc in ranked_inc[:5]: + sid = inc.get("scenario_id") or "" + title = inc.get("title") or sid + sev = inc.get("severity") or "warn" + rel = _question_relevance(question, title, sid) + if rel > 0 or len(actions) < 2: + add_run(sid, f"▶ Executar check: {title[:48]}", sev) + if rel > 0 and inc.get("id"): + add_ack(int(inc["id"]), f"✓ Ack #{inc['id']} · {title[:32]}", sev, sid) + + for s in store.list_scenarios(conn): + if s.get("last_run_status") not in (None, "ok"): + add_run( + s["id"], + f"▶ Re-validar: {(s.get('title') or s['id'])[:42]}", + "warn" if s.get("last_run_status") == "fail" else "info", + ) + + profile = AGENT_CATALOG.get(target_agent) + if profile: + for sid in profile.scenarios: + sc = store.get_scenario(conn, sid) + if sc: + add_run(sid, f"▶ Check: {(sc.get('title') or sid)[:40]}", "info") + + if len(actions) < 2 and target_agent in ("A6", "A0", "sentinel"): + for sid, lbl in ( + ("desk.api.health", "▶ Health Desk API"), + ("proxmox.cluster", "▶ Health Proxmox"), + ("ollama.vm123.health", "▶ Health Ollama VM123"), + ): + add_run(sid, lbl, "info") + + return actions[:limit] diff --git a/projects/ops-desk/api/app/agents/chat_stream.py b/projects/ops-desk/api/app/agents/chat_stream.py new file mode 100644 index 0000000..07fadf1 --- /dev/null +++ b/projects/ops-desk/api/app/agents/chat_stream.py @@ -0,0 +1,137 @@ +"""SSE streaming chat — respostas token-a-token como assistente.""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any + +from app import auth +from app.agents import llm_client, store +from app.agents import messages as agent_messages +from app.agents.chat_context_builder import build_ops_context, build_suggested_actions + + +def _sse(payload: dict[str, Any]) -> str: + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + +def _thread_history_messages(conn, thread_id: int, limit: int = 16) -> list[dict]: + msgs = agent_messages.thread_messages(conn, thread_id)[-limit:] + out: list[dict] = [] + for m in msgs: + if m["from_type"] == "human": + out.append({"role": "user", "content": m["body"]}) + elif m["from_type"] == "agent": + out.append({"role": "assistant", "content": m["body"]}) + return out + + +def chat_stream_generator( + *, + username: str, + user_role: str, + question: str, + target_agent: str, + agent_name: str, + agent_role: str, + include_findings: bool, + thread_id: int | None = None, +) -> Iterator[str]: + conn = auth.db() + try: + kb = store.search_kb(conn, question) + kb_snippets = [k["snippet"] for k in kb] + ops_context = build_ops_context( + conn, + question, + target_agent, + include_findings=include_findings, + kb_snippets=kb_snippets, + ) + suggested_actions = build_suggested_actions(conn, question, target_agent) + + tid = thread_id + history_msgs: list[dict] = [] + if tid: + row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (tid,)).fetchone() + if not row: + yield _sse({"type": "error", "message": "thread not found"}) + return + history_msgs = _thread_history_messages(conn, tid) + else: + tid = agent_messages.create_thread( + conn, + subject=f"Chat: {question[:60]}", + primary_agent=target_agent, + severity="info", + ) + + agent_messages.post_message( + conn, + thread_id=tid, + from_type="human", + from_id=username, + to_type="agent", + to_id=target_agent, + body=question, + ) + conn.commit() + + messages = llm_client.build_chat_messages( + question=question, + kb_snippets=kb_snippets, + ops_context=ops_context, + user_role=user_role, + target_agent=target_agent, + agent_name=agent_name, + agent_role=agent_role, + history_messages=history_msgs, + ) + + yield _sse({"type": "start", "thread_id": tid, "agent": target_agent, "actions": suggested_actions}) + + token_iter, model_label = llm_client.stream_llm_chat(messages) + parts: list[str] = [] + for tok in token_iter: + parts.append(tok) + yield _sse({"type": "token", "text": tok}) + + answer = "".join(parts).strip() + if not answer: + answer = ( + "Não consegui gerar resposta agora. Verifique Groq/Ollama no health do Agentic Ops." + ) + model_label = "error" + yield _sse({"type": "token", "text": answer}) + + agent_messages.post_message( + conn, + thread_id=tid, + from_type="agent", + from_id=target_agent, + to_type="human", + to_id=username, + body=answer, + context={"model": model_label, "kb_hits": len(kb), "streamed": True, "actions": suggested_actions}, + ) + store.log_event( + conn, + event_type="chat.stream" if thread_id else "chat.query", + message=question[:120], + agent_id=target_agent, + payload={"user": username, "model": model_label, "thread_id": tid}, + ) + conn.commit() + yield _sse({ + "type": "done", + "answer": answer, + "model": model_label, + "thread_id": tid, + "kb_hits": len(kb), + "actions": suggested_actions, + }) + except Exception as exc: + yield _sse({"type": "error", "message": str(exc)[:300]}) + finally: + conn.close() diff --git a/projects/ops-desk/api/app/agents/llm_client.py b/projects/ops-desk/api/app/agents/llm_client.py index 178019f..24adcf7 100644 --- a/projects/ops-desk/api/app/agents/llm_client.py +++ b/projects/ops-desk/api/app/agents/llm_client.py @@ -1,7 +1,10 @@ -"""Ollama VM123 + fallback — Spec 029 T0/T1.""" +"""LLM providers — Spec 029 T1 (Ollama) + 034 KIMI + Groq free tier.""" from __future__ import annotations +import json import os +import re +from collections.abc import Iterator import httpx @@ -9,6 +12,60 @@ OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstr AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct") AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text") AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes") +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "").strip() +KIMI_BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1").strip().rstrip("/") +KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-k2.5").strip() +GROQ_API_KEY = os.getenv("GROQ_API_KEY", "").strip() +GROQ_BASE_URL = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1").strip().rstrip("/") +GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant").strip() +AGENTIC_LLM_PROVIDER = os.getenv("AGENTIC_LLM_PROVIDER", "auto").lower().strip() +AGENTIC_LLM_FALLBACK = os.getenv("AGENTIC_LLM_FALLBACK", "ollama").lower().strip() + + +def _clean_env(value: str) -> str: + return re.split(r"\s+#", value or "", maxsplit=1)[0].strip() + + +def resolve_provider() -> str: + if not AGENTIC_LLM_ENABLED: + return "t0" + provider = _clean_env(AGENTIC_LLM_PROVIDER).lower() or "auto" + if provider == "auto": + if GROQ_API_KEY: + return "groq" + if KIMI_API_KEY: + return "kimi" + return "ollama" + return provider + + +def active_model() -> str: + p = resolve_provider() + if p == "groq": + return _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant" + if p == "kimi": + return _clean_env(KIMI_MODEL) or "kimi-k2.5" + if p == "ollama": + return AGENTIC_LLM_MODEL + return "t0" + + +def llm_status() -> dict: + provider = resolve_provider() + return { + "tier": "t1" if AGENTIC_LLM_ENABLED else "t0", + "provider": provider, + "model": active_model(), + "groq_configured": bool(GROQ_API_KEY), + "groq_base_url": _clean_env(GROQ_BASE_URL), + "kimi_configured": bool(KIMI_API_KEY), + "kimi_base_url": _clean_env(KIMI_BASE_URL), + "ollama": ollama_available(), + "ollama_url": OLLAMA_BASE_URL, + "ollama_model": AGENTIC_LLM_MODEL, + "embed_model": AGENTIC_EMBED_MODEL, + "fallback": AGENTIC_LLM_FALLBACK, + } def ollama_available() -> bool: @@ -19,13 +76,80 @@ def ollama_available() -> bool: return False -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}) +def _openai_compatible_chat( + *, + base_url: str, + api_key: str, + model: str, + messages: list[dict], + max_tokens: int, + timeout: float, + prefix: str, +) -> tuple[str, str]: + try: + with httpx.Client(timeout=timeout) as c: + r = c.post( + f"{base_url.rstrip('/')}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "messages": messages, + "temperature": 0.3, + "max_tokens": max_tokens, + }, + ) + if r.status_code == 200: + data = r.json() + choice = (data.get("choices") or [{}])[0] + msg = choice.get("message") or {} + txt = (msg.get("content") or "").strip() + if txt: + return txt, model + if r.status_code == 429: + return ("", f"{prefix}-quota") + return ("", f"{prefix}-error-{r.status_code}") + except Exception: + return ("", f"{prefix}-error") + + +def _groq_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]: + if not GROQ_API_KEY: + return ("", "groq-unconfigured") + model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant" + base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1" + return _openai_compatible_chat( + base_url=base, + api_key=GROQ_API_KEY, + model=model, + messages=messages, + max_tokens=max_tokens, + timeout=30.0, + prefix="groq", + ) + + +def _kimi_chat(messages: list[dict], *, max_tokens: int = 1200) -> tuple[str, str]: + if not KIMI_API_KEY: + return ("", "kimi-unconfigured") + model = _clean_env(KIMI_MODEL) or "kimi-k2.5" + base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1" + return _openai_compatible_chat( + base_url=base, + api_key=KIMI_API_KEY, + model=model, + messages=messages, + max_tokens=max_tokens, + timeout=45.0, + prefix="kimi", + ) + + +def _ollama_chat(messages: list[dict], *, max_tokens: int = 800) -> tuple[str, str]: + if not ollama_available(): + return ("", "ollama-offline") try: with httpx.Client(timeout=120.0) as c: r = c.post( @@ -38,18 +162,241 @@ def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> t return txt, AGENTIC_LLM_MODEL except Exception: pass + return ("", "ollama-error") + + +def _try_fallback(messages: list[dict], *, max_tokens: int, skip: str) -> tuple[str, str]: + order = [] + if AGENTIC_LLM_FALLBACK == "ollama" and skip != "ollama": + order.append("ollama") + if skip != "groq" and GROQ_API_KEY: + order.append("groq") + if skip != "kimi" and KIMI_API_KEY: + order.append("kimi") + for fb in order: + if fb == "ollama": + txt, model = _ollama_chat(messages, max_tokens=max_tokens) + elif fb == "groq": + txt, model = _groq_chat(messages, max_tokens=max_tokens) + else: + txt, model = _kimi_chat(messages, max_tokens=max_tokens) + if txt: + return txt, f"{fb}:{model}" return ("", "t0-fallback") +def _llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[str, str]: + if not AGENTIC_LLM_ENABLED: + return ("", "t0") + provider = resolve_provider() + dispatch = { + "groq": _groq_chat, + "kimi": _kimi_chat, + "ollama": _ollama_chat, + } + fn = dispatch.get(provider) + if not fn: + return ("", "t0") + txt, model = fn(messages, max_tokens=max_tokens) + if txt: + return txt, f"{provider}:{model.split(':')[-1] if ':' in model else model}" + if model.endswith("-quota"): + return ("", model) + return _try_fallback(messages, max_tokens=max_tokens, skip=provider) + + +def _openai_compatible_stream( + *, + base_url: str, + api_key: str, + model: str, + messages: list[dict], + max_tokens: int, + timeout: float, +) -> Iterator[str]: + try: + with httpx.Client(timeout=timeout) as c: + with c.stream( + "POST", + f"{base_url.rstrip('/')}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": model, + "messages": messages, + "temperature": 0.5, + "max_tokens": max_tokens, + "stream": True, + }, + ) as r: + if r.status_code != 200: + return + for line in r.iter_lines(): + if not line or not line.startswith("data: "): + continue + payload = line[6:].strip() + if payload == "[DONE]": + break + try: + data = json.loads(payload) + except json.JSONDecodeError: + continue + delta = (data.get("choices") or [{}])[0].get("delta") or {} + txt = delta.get("content") or "" + if txt: + yield txt + except Exception: + return + + +def _groq_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]: + if not GROQ_API_KEY: + return + model = _clean_env(GROQ_MODEL) or "llama-3.1-8b-instant" + base = _clean_env(GROQ_BASE_URL) or "https://api.groq.com/openai/v1" + yield from _openai_compatible_stream( + base_url=base, + api_key=GROQ_API_KEY, + model=model, + messages=messages, + max_tokens=max_tokens, + timeout=60.0, + ) + + +def _kimi_stream(messages: list[dict], *, max_tokens: int = 1200) -> Iterator[str]: + if not KIMI_API_KEY: + return + model = _clean_env(KIMI_MODEL) or "kimi-k2.5" + base = _clean_env(KIMI_BASE_URL) or "https://api.moonshot.ai/v1" + yield from _openai_compatible_stream( + base_url=base, + api_key=KIMI_API_KEY, + model=model, + messages=messages, + max_tokens=max_tokens, + timeout=60.0, + ) + + +def _ollama_stream(messages: list[dict], *, max_tokens: int = 800) -> Iterator[str]: + if not ollama_available(): + return + try: + with httpx.Client(timeout=120.0) as c: + with c.stream( + "POST", + f"{OLLAMA_BASE_URL}/api/chat", + json={"model": AGENTIC_LLM_MODEL, "messages": messages, "stream": True}, + ) as r: + for line in r.iter_lines(): + if not line: + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + chunk = (data.get("message") or {}).get("content") or "" + if chunk: + yield chunk + if data.get("done"): + break + except Exception: + return + + +def stream_llm_chat(messages: list[dict], *, max_tokens: int = 2000) -> tuple[Iterator[str], str]: + """Returns (token iterator, provider label). Falls back to burst if stream empty.""" + if not AGENTIC_LLM_ENABLED: + return iter(()), "t0" + + provider = resolve_provider() + dispatch = {"groq": _groq_stream, "kimi": _kimi_stream, "ollama": _ollama_stream} + fn = dispatch.get(provider) + if not fn: + return iter(()), "t0" + + def _gen(): + got = False + for tok in fn(messages, max_tokens=max_tokens): + got = True + yield tok + if not got: + txt, model = _llm_chat(messages, max_tokens=max_tokens) + if txt: + yield txt + + label = f"{provider}:{active_model()}" + return _gen(), label + + +def build_chat_messages( + *, + question: str, + kb_snippets: list[str] | None = None, + findings_summary: str | None = None, + ops_context: str | None = None, + user_role: str = "technician", + target_agent: str = "A6", + agent_name: str = "Copiloto", + agent_role: str = "Assistência tickets e janela humana", + history_messages: list[dict] | None = None, +) -> list[dict]: + from app.agents.chat_context_builder import RESPONSE_FORMAT_INSTRUCTION + + ctx_block = ops_context or "" + if not ctx_block: + ctx_parts = [f"Operador: {user_role} (Ligbox Desk)"] + if findings_summary: + ctx_parts.append(f"Findings abertos:\n{findings_summary[:2000]}") + if kb_snippets: + ctx_parts.append("Base de conhecimento relevante:\n" + "\n---\n".join(kb_snippets[:6])[:4000]) + ctx_block = "\n\n".join(ctx_parts) + + system = ( + f"Você é **{agent_name}** ({target_agent}) — {agent_role}.\n" + "Actua como **investigador DevOps sénior** no ecossistema Ligbox, não como FAQ genérico.\n\n" + "## Missão\n" + "Analisar o contexto operacional real, formular hipóteses fundamentadas e propor " + "**acções concretas** que o operador possa executar agora.\n\n" + "## Dados operacionais injectados\n" + f"{ctx_block}\n\n" + f"{RESPONSE_FORMAT_INSTRUCTION}" + ) + messages: list[dict] = [{"role": "system", "content": system}] + for hm in history_messages or []: + role = hm.get("role") + content = (hm.get("content") or "").strip() + if role in ("user", "assistant") and content: + messages.append({"role": role, "content": content}) + messages.append({ + "role": "user", + "content": ( + f"Pergunta do operador ({user_role}):\n{question}\n\n" + "Responda no formato investigativo obrigatório. " + "Use os incidentes/findings/cenários do contexto quando relevantes." + ), + }) + return messages + + 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}\n" - f"KB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}" - ) - txt, model = _chat(prompt) + kb = "---".join(kb_snippets or [])[:2500] or "N/A" + messages = [ + { + "role": "system", + "content": "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?", + }, + { + "role": "user", + "content": f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {kb}", + }, + ] + txt, model = _llm_chat(messages, max_tokens=400) if txt: return txt, model return (f"Investigar manualmente: {finding_title}", "t0") @@ -60,27 +407,43 @@ def chat_context( question: str, kb_snippets: list[str] | None = None, findings_summary: str | None = None, + ops_context: str | None = None, user_role: str = "technician", + target_agent: str = "A6", + agent_name: str = "Copiloto", + agent_role: str = "Assistência tickets e janela humana", + history: str | None = None, + history_messages: list[dict] | None = None, ) -> 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." + # Legacy string history → single user turn if no structured history + hist_msgs = list(history_messages or []) + if not hist_msgs and history: + hist_msgs = [{"role": "user", "content": f"[Histórico resumido]\n{history[:3000]}"}] + + messages = build_chat_messages( + question=question, + kb_snippets=kb_snippets, + findings_summary=findings_summary, + ops_context=ops_context, + user_role=user_role, + target_agent=target_agent, + agent_name=agent_name, + agent_role=agent_role, + history_messages=hist_msgs, ) - 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) + txt, model = _llm_chat(messages, max_tokens=1200) if txt: return txt, model + if model == "groq-quota": + return ( + "Quota Groq esgotada (free tier). Tente amanhã ou use fallback Ollama local.", + "groq-quota", + ) + if model == "kimi-quota": + return ( + "Conta KIMI sem saldo. Groq/Ollama disponíveis como alternativa.", + "kimi-quota", + ) return ( "Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.", "t0", diff --git a/projects/ops-desk/api/app/agents/messages.py b/projects/ops-desk/api/app/agents/messages.py index d70c4ed..e10961b 100644 --- a/projects/ops-desk/api/app/agents/messages.py +++ b/projects/ops-desk/api/app/agents/messages.py @@ -275,17 +275,6 @@ def human_reply( requires_human=False, ) - # Copiloto (A6) ecoa confirmação para o thread - post_message( - conn, - thread_id=tid, - from_type="agent", - from_id="A6", - to_type="human", - to_id=username, - body=f"Recebi a sua instrução. Vou coordenar com **{AGENT_CATALOG.get(agent_to, AGENT_CATALOG['A6']).name}** e actualizar o finding se aplicável.", - requires_human=False, - ) return mid diff --git a/projects/ops-desk/api/app/agents/routes.py b/projects/ops-desk/api/app/agents/routes.py index 19ce954..d19961e 100644 --- a/projects/ops-desk/api/app/agents/routes.py +++ b/projects/ops-desk/api/app/agents/routes.py @@ -5,12 +5,15 @@ import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field -from app import auth +from app import agent_bindings, auth from app.agents import llm_client, runner, store from app.agents import messages as agent_messages -from app.agents.catalog import roster_public +from app.agents.catalog import AGENT_CATALOG, roster_public +from app.agents.chat_stream import chat_stream_generator, _thread_history_messages +from app.agents.chat_context_builder import build_ops_context router = APIRouter(prefix="/api/v1/agents", tags=["agents"]) @@ -23,20 +26,17 @@ def _db(): conn.close() -def _ops_view(user): - if user.role not in ( - "super_admin", - "ops_lead", - "technician", - "noc", - "agentic_operator", - "developer", - "devops", - "security_analyst", - ): +def _ops_view(user, conn): + if not agent_bindings.can_use_agentics_ui(conn, user.role): raise HTTPException(403, "insufficient permissions") +def _require_agent_chat(user, conn, agent_id: str): + _ops_view(user, conn) + if not agent_bindings.can_chat_agent(conn, user.role, agent_id): + raise HTTPException(403, f"sem atribuição Agentics para agente {agent_id}") + + class ChatRequest(BaseModel): question: str = Field(..., min_length=2, max_length=4000) include_findings: bool = True @@ -50,19 +50,12 @@ class ReplyRequest(BaseModel): @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, - "embed_model": llm_client.AGENTIC_EMBED_MODEL, - } + return {"status": "ok", **llm_client.llm_status()} @router.get("/overview") def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) return store.get_overview(conn) @@ -75,13 +68,13 @@ def agents_incidents( agent_id: str | None = None, limit: int = Query(50, ge=1, le=200), ): - _ops_view(user) + _ops_view(user, conn) return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)} @router.get("/incidents/{incident_id}") def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) inc = store.get_incident(conn, incident_id) if not inc: raise HTTPException(404, "incident not found") @@ -94,7 +87,7 @@ def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user) @router.post("/incidents/{incident_id}/ack") def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) inc = store.ack_incident(conn, incident_id, user.username) if not inc: raise HTTPException(404, "incident not found") @@ -105,7 +98,7 @@ def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Dep @router.get("/timeline") def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)): - _ops_view(user) + _ops_view(user, conn) ticks = [ dict(r) for r in conn.execute( @@ -128,31 +121,60 @@ def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limi @router.get("/roster") -def agents_roster(user=Depends(auth.get_current_user)): - _ops_view(user) +def agents_roster(user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user, conn) return {"agents": roster_public()} @router.get("/inbox") def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)): - _ops_view(user) + _ops_view(user, conn) return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)} @router.get("/threads") def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)): - _ops_view(user) + _ops_view(user, conn) return {"threads": agent_messages.list_threads(conn, limit=limit)} @router.get("/threads/{thread_id}/messages") def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone(): raise HTTPException(404, "thread not found") return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)} +def _agent_profile(agent_id: str): + return AGENT_CATALOG.get(agent_id, AGENT_CATALOG["A6"]) + + +def _findings_summary(conn, include: bool) -> str: + if not include: + return "" + open_f = store.list_findings(conn, limit=8, open_only=True) + if not open_f: + return "" + return "\n".join( + f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f + ) + + +def _thread_history(conn, thread_id: int, limit: int = 10) -> str: + msgs = agent_messages.thread_messages(conn, thread_id)[-limit:] + return "\n".join(f"{m.get('from_label') or m.get('from_id')}: {m.get('body', '')[:240]}" for m in msgs) + + +def _chat_ops_context(conn, question: str, target_agent: str, include: bool) -> tuple[list[str], str]: + kb = store.search_kb(conn, question) + snippets = [k["snippet"] for k in kb] + ctx = build_ops_context( + conn, question, target_agent, include_findings=include, kb_snippets=snippets + ) + return snippets, ctx + + @router.post("/threads/{thread_id}/reply") def thread_reply( thread_id: int, @@ -160,26 +182,116 @@ def thread_reply( user=Depends(auth.get_current_user), conn=Depends(_db), ): - _ops_view(user) - if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone(): + _ops_view(user, conn) + row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() + if not row: raise HTTPException(404, "thread not found") + target = body.target_agent or row["primary_agent"] mid = agent_messages.human_reply( - conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=body.target_agent + conn, thread_id=thread_id, username=user.username, body=body.body, target_agent=target ) store.log_event( conn, event_type="human.reply", message=body.body[:120], - agent_id=body.target_agent or "A6", + agent_id=target, payload={"thread_id": thread_id, "user": user.username}, ) conn.commit() return {"ok": True, "message_id": mid} +@router.post("/threads/{thread_id}/chat") +def thread_chat( + thread_id: int, + body: ChatRequest, + user=Depends(auth.get_current_user), + conn=Depends(_db), +): + """Continuar conversa com agente (LLM) numa thread existente.""" + row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() + if not row: + raise HTTPException(404, "thread not found") + target = body.target_agent or row["primary_agent"] + _require_agent_chat(user, conn, target) + profile = _agent_profile(target) + kb_snippets, ops_context = _chat_ops_context(conn, body.question, target, body.include_findings) + answer, model = llm_client.chat_context( + question=body.question, + kb_snippets=kb_snippets, + ops_context=ops_context, + user_role=user.role, + target_agent=target, + agent_name=profile.name, + agent_role=profile.role, + history_messages=_thread_history_messages(conn, thread_id), + ) + agent_messages.post_message( + conn, + thread_id=thread_id, + from_type="human", + from_id=user.username, + to_type="agent", + to_id=target, + body=body.question, + ) + agent_messages.post_message( + conn, + thread_id=thread_id, + from_type="agent", + from_id=target, + to_type="human", + to_id=user.username, + body=answer, + context={"model": model, "kb_hits": len(kb_snippets)}, + ) + store.log_event( + conn, + event_type="chat.thread", + message=body.question[:120], + agent_id=target, + payload={"user": user.username, "model": model, "thread_id": thread_id}, + ) + conn.commit() + return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id} + + +@router.post("/threads/{thread_id}/chat/stream") +def thread_chat_stream( + thread_id: int, + body: ChatRequest, + user=Depends(auth.get_current_user), +): + """Chat com streaming SSE — resposta token a token.""" + _ops_view(user, conn) + conn = auth.db() + try: + row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone() + if not row: + raise HTTPException(404, "thread not found") + target = body.target_agent or row["primary_agent"] + profile = _agent_profile(target) + finally: + conn.close() + return StreamingResponse( + chat_stream_generator( + username=user.username, + user_role=user.role, + question=body.question, + target_agent=target, + agent_name=profile.name, + agent_role=profile.role, + include_findings=body.include_findings, + thread_id=thread_id, + ), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + @router.post("/messages/{message_id}/ack") def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) if not agent_messages.ack_message(conn, message_id, user.username): raise HTTPException(404, "not found") conn.commit() @@ -188,7 +300,7 @@ def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn @router.get("/scenarios") def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)): - _ops_view(user) + _ops_view(user, conn) runner.sync_registry(conn) conn.commit() return {"scenarios": store.list_scenarios(conn)} @@ -202,13 +314,13 @@ def list_findings( limit: int = Query(50, ge=1, le=200), open_only: bool = True, ): - _ops_view(user) + _ops_view(user, conn) 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) + _ops_view(user, conn) 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() @@ -223,13 +335,36 @@ def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depen @router.get("/action-log") def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)): - _ops_view(user) + _ops_view(user, conn) return {"events": store.list_action_log(conn, limit=limit)} +@router.get("/kb/sources") +def kb_sources(user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user, conn) + rows = conn.execute( + """SELECT source_path, COUNT(*) AS chunks, MAX(indexed_at) AS indexed_at + FROM agent_kb_chunks GROUP BY source_path ORDER BY source_path""" + ).fetchall() + total = conn.execute("SELECT COUNT(*) c FROM agent_kb_chunks").fetchone()["c"] + return {"sources": [dict(r) for r in rows], "total_chunks": total} + + +@router.get("/kb/search") +def kb_search( + user=Depends(auth.get_current_user), + conn=Depends(_db), + q: str = Query(..., min_length=1, max_length=500), + limit: int = Query(12, ge=1, le=50), +): + _ops_view(user, conn) + hits = store.search_kb(conn, q, limit=limit) + return {"query": q, "hits": hits, "count": len(hits)} + + @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", "agentic_operator"): + if not agent_bindings.can_trigger_runs(conn, user.role): raise HTTPException(403, "insufficient permissions") r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}") conn.commit() @@ -239,20 +374,17 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe @router.post("/chat") def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): """Janela de contexto T1 — humano ↔ agente (default Copiloto A6).""" - _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 - ) + _require_agent_chat(user, conn, body.target_agent) + profile = _agent_profile(body.target_agent) + kb_snippets, ops_context = _chat_ops_context(conn, body.question, body.target_agent, body.include_findings) answer, model = llm_client.chat_context( question=body.question, - kb_snippets=[k["snippet"] for k in kb], - findings_summary=findings_summary, + kb_snippets=kb_snippets, + ops_context=ops_context, user_role=user.role, + target_agent=body.target_agent, + agent_name=profile.name, + agent_role=profile.role, ) thread_id = agent_messages.create_thread( conn, @@ -277,7 +409,7 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe to_type="human", to_id=user.username, body=answer, - context={"model": model, "kb_hits": len(kb)}, + context={"model": model, "kb_hits": len(kb_snippets)}, ) store.log_event( conn, @@ -287,7 +419,28 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe payload={"user": user.username, "model": model, "thread_id": thread_id}, ) conn.commit() - return {"answer": answer, "model": model, "kb_hits": len(kb), "thread_id": thread_id} + return {"answer": answer, "model": model, "kb_hits": len(kb_snippets), "thread_id": thread_id} + + +@router.post("/chat/stream") +def agent_chat_stream(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)): + """Nova conversa com streaming SSE.""" + _require_agent_chat(user, conn, body.target_agent) + profile = _agent_profile(body.target_agent) + return StreamingResponse( + chat_stream_generator( + username=user.username, + user_role=user.role, + question=body.question, + target_agent=body.target_agent, + agent_name=profile.name, + agent_role=profile.role, + include_findings=body.include_findings, + thread_id=None, + ), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) @router.post("/internal/tick") diff --git a/projects/ops-desk/api/app/agents/store.py b/projects/ops-desk/api/app/agents/store.py index 6424c6a..eb46356 100644 --- a/projects/ops-desk/api/app/agents/store.py +++ b/projects/ops-desk/api/app/agents/store.py @@ -298,9 +298,7 @@ def get_overview(conn) -> dict: except json.JSONDecodeError: pass return { - "tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0", - "ollama": llm_client.ollama_available(), - "model": llm_client.AGENTIC_LLM_MODEL, + **llm_client.llm_status(), "last_tick_at": last_tick["ts"] if last_tick else None, "last_tick_status": "degraded" if payload.get("runs") and any( r.get("findings_count", 0) > 0 for r in payload.get("runs", []) if isinstance(r, dict) diff --git a/projects/ops-desk/api/app/main.py b/projects/ops-desk/api/app/main.py index 7a066a6..0b5a42f 100644 --- a/projects/ops-desk/api/app/main.py +++ b/projects/ops-desk/api/app/main.py @@ -29,6 +29,7 @@ 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.rbac_routes import router as rbac_router from app.agents.store import init_agent_schema from app.collectors.base import run_audit from app.permissions import ( @@ -136,6 +137,7 @@ app.include_router(billing_router) app.include_router(infra_stack_router) app.include_router(vm123_router) app.include_router(agents_router) +app.include_router(rbac_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" @@ -189,6 +191,9 @@ def init_db(): init_purge_jobs_schema(conn) init_purge_auth_schema(conn) init_agent_schema(conn) + from app import agent_bindings + + agent_bindings.init_schema(conn) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=60000") conn.commit() diff --git a/projects/ops-desk/api/app/rbac_matrix.py b/projects/ops-desk/api/app/rbac_matrix.py new file mode 100644 index 0000000..4971f7a --- /dev/null +++ b/projects/ops-desk/api/app/rbac_matrix.py @@ -0,0 +1,495 @@ +"""Exportação read-only da matriz Spec 027 para UI preview.""" + +from __future__ import annotations + +import os +from typing import Any + +from app.platform_role_catalog import catalog_export +from app.agents.catalog import AGENT_CATALOG +from app import agent_bindings + +# Legenda global Spec 027 +LEGEND = { + "full": {"symbol": "✅", "label": "Acesso total", "access": "full"}, + "read": {"symbol": "🔒", "label": "Só leitura", "access": "read"}, + "link": {"symbol": "🔗", "label": "Deep-link", "access": "link"}, + "api": {"symbol": "⚙️", "label": "Via API Desk", "access": "api"}, + "system": {"symbol": "🤖", "label": "Conta sistema", "access": "system"}, + "none": {"symbol": "❌", "label": "Sem acesso", "access": "none"}, +} + +ROLE_COLUMNS = [ + "super_admin", "ops_lead", "technician", "noc", "finance", + "sales_admin", "sales_support", "marketing", "seo", "developer", + "devops", "security_analyst", "content_editor", "agentic_operator", +] + +# Spec 027 §3.1 — módulos Desk × função +DESK_MODULE_MATRIX: dict[str, dict[str, str]] = { + "core": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "read", "sales_admin": "full", "sales_support": "full", + "marketing": "read", "seo": "read", "developer": "read", "devops": "read", + "security_analyst": "read", "content_editor": "read", "agentic_operator": "read", + }, + "overview": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "read", "sales_admin": "full", "sales_support": "read", + "marketing": "read", "seo": "read", "developer": "read", "devops": "read", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "read", + }, + "overview-home": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "read", "sales_admin": "full", "sales_support": "full", + "marketing": "read", "seo": "read", "developer": "read", "devops": "full", + "security_analyst": "read", "content_editor": "read", "agentic_operator": "read", + }, + "infra": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "read", "developer": "read", "devops": "none", + "security_analyst": "full", "content_editor": "none", "agentic_operator": "read", + }, + "infra2-soc": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "full", "content_editor": "none", "agentic_operator": "read", + }, + "funnel-timing": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "read", "sales_admin": "full", "sales_support": "full", + "marketing": "full", "seo": "full", "developer": "read", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "read", + }, + "wazuh-soc": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "read", + "security_analyst": "full", "content_editor": "none", "agentic_operator": "read", + }, + "leads": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "none", + "finance": "read", "sales_admin": "full", "sales_support": "full", + "marketing": "full", "seo": "full", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "events": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "read", "sales_admin": "read", "sales_support": "read", + "marketing": "read", "seo": "read", "developer": "api", "devops": "api", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "api", + }, + "tenants": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "read", "sales_admin": "full", "sales_support": "read", + "marketing": "read", "seo": "read", "developer": "read", "devops": "read", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "read", + }, + "messages": { + "super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "admin-users": { + "super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "billing-recurrence": { + "super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "none", + "finance": "full", "sales_admin": "full", "sales_support": "read", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "migration": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "read", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "assist": { + "super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, + "modules": { + "super_admin": "full", "ops_lead": "none", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none", + }, +} + +DESK_MODULE_LABELS: dict[str, str] = { + "core": "Núcleo (dashboard/tickets)", + "overview": "Audit Overview", + "overview-home": "Serviços IaaS", + "infra": "INFRA CODE", + "infra2-soc": "Infra 2 SOC", + "funnel-timing": "Relógio por fase", + "wazuh-soc": "Wazuh SOC", + "leads": "Leads / abandono CRM", + "events": "Eventos / webhooks", + "tenants": "Tenants", + "messages": "Mensagens (cadastro)", + "admin-users": "Administradores", + "billing-recurrence": "Billing recorrente", + "migration": "Migração e-mail", + "assist": "Assist / takeover", + "modules": "Toggle módulos", +} + +AGENTS_LEGACY = [ + {"id": "A0", "name": "Maestro", "role": "Orquestrador multi-agente", "approval": "agentic_operator / ops_lead"}, + {"id": "A1", "name": "Node Health", "role": "CPU, RAM, serviços Carbonio", "approval": "ops_lead (restart)"}, + {"id": "A2", "name": "Infra Mail", "role": "DNS, LE, Traefik, nginx", "approval": "devops / ops_lead"}, + {"id": "A3", "name": "Deliverability", "role": "SPF/DKIM/DMARC", "approval": "seo / technician"}, + {"id": "A4", "name": "Security Mail", "role": "amavis, spam, clamav", "approval": "security_analyst"}, + {"id": "A5", "name": "Wazuh SOC", "role": "Correlação SIEM", "approval": "security_analyst / noc"}, + {"id": "A6", "name": "Support Copilot", "role": "Assistência tickets", "approval": "technician"}, + {"id": "A7", "name": "Remediation", "role": "Runbooks", "approval": "agentic_operator (obrigatório)"}, +] + +# Spec 027 §6 — governança Agentics (fonte: catalog.py + permissions + agents/routes.py) +AGENTIC_GOVERNANCE: dict[str, Any] = { + "use_ui": [ + "super_admin", "ops_lead", "technician", "noc", + "agentic_operator", "developer", "devops", "security_analyst", + ], + "trigger_runs": ["super_admin", "ops_lead", "agentic_operator"], + "approve_runbooks": ["super_admin", "ops_lead", "agentic_operator", "security_analyst"], + "configure_models": ["super_admin", "developer"], + "labels": { + "use_ui": "Usa módulo Agentics (chat, findings, inbox)", + "trigger_runs": "Dispara cenários manualmente", + "approve_runbooks": "Aprova runbooks A7 / remediação", + "configure_models": "Configura modelos/prompts LLM", + }, +} + +# Por agente: quem aprova acções sensíveis vs quem opera no dia-a-dia +AGENT_ROLE_MAP: dict[str, dict[str, list[str]]] = { + "A0": { + "approvers": ["agentic_operator", "ops_lead"], + "operators": ["super_admin", "ops_lead", "agentic_operator"], + }, + "A1": { + "approvers": ["ops_lead"], + "operators": ["ops_lead", "devops", "noc", "super_admin", "agentic_operator"], + }, + "A2": { + "approvers": ["devops", "ops_lead"], + "operators": ["devops", "ops_lead", "developer", "super_admin"], + }, + "A3": { + "approvers": ["seo", "technician"], + "operators": ["seo", "technician", "ops_lead", "super_admin"], + }, + "A4": { + "approvers": ["security_analyst"], + "operators": ["security_analyst", "ops_lead", "super_admin"], + }, + "A5": { + "approvers": ["security_analyst", "noc"], + "operators": ["security_analyst", "noc", "ops_lead", "agentic_operator", "super_admin"], + }, + "A6": { + "approvers": ["technician"], + "operators": ["technician", "ops_lead", "agentic_operator", "super_admin"], + }, + "A7": { + "approvers": ["agentic_operator", "ops_lead", "super_admin"], + "operators": ["agentic_operator", "ops_lead", "super_admin"], + }, +} + +AGENT_RELATION_LABELS = { + "approve": "Aprova acções sensíveis", + "focus": "Operador principal", + "ui": "Acede UI Agentics", +} + +CATEGORY_LABELS = { + "ops": "Operações", + "commercial": "Comercial", + "business": "Negócio", + "platform": "Plataforma", + "system": "Sistema", +} + +# Spec 027 §2 / §4 / §5 — software em escopo (fora do Desk, dentro da plataforma Ligbox) +SOFTWARE_GROUPS: list[dict[str, Any]] = [ + { + "id": "vm112", + "label": "VM112 — Onboard & Mail", + "host": "10.10.10.112", + "url": "https://onboard.ligbox.com.br", + "items": [ + {"id": "wizard_assist", "label": "Wizard assist / takeover", "product": "ligbox-wizard", + "levels": {"super_admin": "full", "ops_lead": "full", "technician": "full", "noc": "read", + "finance": "read", "marketing": "read", "seo": "read", "developer": "api", + "sales_admin": "read", "sales_support": "read", "devops": "read", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "read"}}, + {"id": "vm112_api", "label": "API VM112 (:8090)", "product": "ligbox-wizard", + "levels": {"super_admin": "full", "ops_lead": "full", "technician": "api", "noc": "read", + "finance": "read", "marketing": "read", "seo": "read", "developer": "full", + "sales_admin": "read", "sales_support": "read", "devops": "full", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "api"}}, + {"id": "carbonio_admin", "label": "Carbonio admin (mail tenant)", "product": "Carbonio", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "link", "noc": "none", + "finance": "none", "marketing": "none", "seo": "none", "developer": "none", + "sales_admin": "none", "sales_support": "none", "devops": "link", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "vm112_ssh", "label": "SSH VM112", "product": "Linux", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "none", "marketing": "none", "seo": "none", "developer": "link", + "sales_admin": "none", "sales_support": "none", "devops": "full", + "security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "purge_domain", "label": "Purge domínio", "product": "ligbox-wizard", + "levels": {"super_admin": "full", "ops_lead": "full", "technician": "none", "noc": "none", + "finance": "none", "marketing": "none", "seo": "none", "developer": "none", + "sales_admin": "none", "sales_support": "none", "devops": "api", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + ], + }, + { + "id": "vm123", + "label": "VM123 — Finance & Hosting", + "host": "10.10.10.123", + "url": "https://financeiro.ligbox.com.br", + "items": [ + {"id": "foss_admin", "label": "FOSSBilling Admin", "product": "FOSSBilling", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "full", "sales_admin": "full", "sales_support": "full", + "marketing": "read", "seo": "none", "developer": "api", "devops": "none", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "foss_client", "label": "FOSSBilling Cliente", "product": "FOSSBilling", + "levels": {"super_admin": "full", "ops_lead": "read", "technician": "none", "noc": "none", + "finance": "full", "sales_admin": "full", "sales_support": "full", + "marketing": "full", "seo": "none", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "odoo16", "label": "Odoo 16 (CRM / Invoicing)", "product": "Odoo", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "full", "sales_admin": "full", "sales_support": "full", + "marketing": "none", "seo": "none", "developer": "api", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "openpanel", "label": "OpenPanel (hosting sites)", "product": "OpenPanel", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "link", "noc": "none", + "finance": "read", "sales_admin": "link", "sales_support": "link", + "marketing": "full", "seo": "full", "developer": "api", "devops": "full", + "security_analyst": "read", "content_editor": "full", "agentic_operator": "none"}}, + {"id": "openadmin", "label": "OpenAdmin", "product": "OpenPanel", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "read", "sales_admin": "link", "sales_support": "none", + "marketing": "link", "seo": "link", "developer": "none", "devops": "full", + "security_analyst": "read", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "vm123_ssh", "label": "SSH VM123 (:2523)", "product": "Linux", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "link", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "link", "devops": "full", + "security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}}, + ], + }, + { + "id": "externas", + "label": "Consolas & Infra (deep-link / API)", + "host": "—", + "url": "", + "items": [ + {"id": "cloudflare", "label": "Cloudflare DNS", "product": "Cloudflare", + "levels": {"super_admin": "full", "ops_lead": "full", "technician": "link", "noc": "none", + "finance": "none", "sales_admin": "read", "sales_support": "read", + "marketing": "read", "seo": "full", "developer": "none", "devops": "full", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "traefik", "label": "Traefik CT114", "product": "Traefik", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "link", "devops": "full", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "proxmox", "label": "Proxmox host", "product": "Proxmox", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "full", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "pfsense", "label": "pfSense API", "product": "pfSense", + "levels": {"super_admin": "full", "ops_lead": "link", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "full", + "security_analyst": "link", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "wazuh", "label": "Wazuh VM104", "product": "Wazuh", + "levels": {"super_admin": "full", "ops_lead": "full", "technician": "read", "noc": "read", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "none", "devops": "read", + "security_analyst": "full", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "github", "label": "GitHub itecnologys/*", "product": "GitHub", + "levels": {"super_admin": "full", "ops_lead": "read", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "none", "seo": "none", "developer": "full", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + {"id": "gsc", "label": "Google Search Console", "product": "Google", + "levels": {"super_admin": "none", "ops_lead": "none", "technician": "none", "noc": "none", + "finance": "none", "sales_admin": "none", "sales_support": "none", + "marketing": "link", "seo": "full", "developer": "none", "devops": "none", + "security_analyst": "none", "content_editor": "none", "agentic_operator": "none"}}, + ], + }, +] + + +def _software_groups_export() -> list[dict[str, Any]]: + out = [] + for grp in SOFTWARE_GROUPS: + items = [] + for item in grp["items"]: + levels = item.get("levels", {}) + items.append({ + **item, + "levels": {role: levels.get(role, "none") for role in ROLE_COLUMNS}, + }) + out.append({**grp, "items": items}) + return out + + +def _agent_role_relations_from_db(conn, agent_id: str, role_id: str) -> list[str]: + return agent_bindings.agent_relations(conn, agent_id, role_id) + + +def _agents_export(conn) -> list[dict[str, Any]]: + order = ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7"] + matrix = agent_bindings.load_bindings_matrix(conn) + out: list[dict[str, Any]] = [] + for aid in order: + p = AGENT_CATALOG.get(aid) + if not p: + continue + mapping = AGENT_ROLE_MAP.get(aid, {}) + role_levels = {role: _agent_role_relations_from_db(conn, aid, role) for role in ROLE_COLUMNS} + role_bindings = matrix["agents"].get(aid, {}) + out.append({ + "id": p.id, + "codename": p.codename, + "name": p.name, + "role": p.role, + "reads": list(p.reads), + "actions": list(p.actions), + "approval_text": p.approval, + "approvers": mapping.get("approvers", []), + "operators": mapping.get("operators", []), + "scenarios": list(p.scenarios), + "role_relations": role_levels, + "bindings": role_bindings, + }) + return out + + +def _role_agents_summary(conn, role_id: str) -> list[dict[str, Any]]: + rows = [] + for agent in _agents_export(conn): + rels = agent["role_relations"].get(role_id, []) + if not rels: + continue + rows.append({ + "id": agent["id"], + "name": agent["name"], + "role": agent["role"], + "relations": rels, + "relation_labels": [AGENT_RELATION_LABELS[r] for r in rels if r in AGENT_RELATION_LABELS], + "is_approver": "approve" in rels, + "is_operator": "focus" in rels, + }) + return rows + + +def _role_agentic_caps(conn, role_id: str) -> list[dict[str, str]]: + return agent_bindings.role_agentic_caps(conn, role_id) + + +def _role_software_summary(role_id: str) -> list[dict[str, Any]]: + """Visão «cartão de função» — todos os SW onde a função tem acesso ≠ none.""" + rows = [] + for grp in SOFTWARE_GROUPS: + for item in grp["items"]: + lv = item.get("levels", {}).get(role_id, "none") + if lv == "none": + continue + rows.append({ + "group": grp["label"], + "product": item.get("product", ""), + "label": item["label"], + "level": lv, + "host": grp.get("host", ""), + "url": grp.get("url", ""), + }) + return rows + + +def _all_role_software_summaries() -> dict[str, list[dict[str, Any]]]: + return {role: _role_software_summary(role) for role in ROLE_COLUMNS} + + +def _desk_modules_export() -> list[dict[str, Any]]: + rows = [] + for mod_id, levels in DESK_MODULE_MATRIX.items(): + rows.append({ + "id": mod_id, + "label": DESK_MODULE_LABELS.get(mod_id, mod_id), + "levels": {role: levels.get(role, "none") for role in ROLE_COLUMNS}, + }) + return rows + + +def matrix_export(conn) -> dict[str, Any]: + catalog = catalog_export() + bindings_matrix = agent_bindings.load_bindings_matrix(conn) + edit_enabled = os.getenv("ACCESS_MATRIX_EDIT", "0").strip().lower() in ("1", "true", "yes", "on") + return { + "spec": "027", + "preview_mode": not edit_enabled, + "read_only": not edit_enabled, + "editable": edit_enabled, + "legend": LEGEND, + "role_columns": ROLE_COLUMNS, + "category_labels": CATEGORY_LABELS, + "desk_modules": _desk_modules_export(), + "software_groups": _software_groups_export(), + "role_software_summaries": _all_role_software_summaries(), + "role_agents_summaries": {role: _role_agents_summary(conn, role) for role in ROLE_COLUMNS}, + "role_agentic_caps": {role: _role_agentic_caps(conn, role) for role in ROLE_COLUMNS}, + "agent_bindings_matrix": bindings_matrix, + "agentic_governance": AGENTIC_GOVERNANCE, + "agent_relation_labels": AGENT_RELATION_LABELS, + "agents": _agents_export(conn), + "catalog": catalog, + "tabs": [ + {"id": "overview", "label": "Visão da função", "type": "overview", + "hint": "Desk + software + APIs da função seleccionada"}, + {"id": "vm122", "label": "Matriz Desk", "type": "desk_modules", + "hint": "VM122 — módulos internos da plataforma"}, + {"id": "software", "label": "Software & Infra", "type": "software", + "hint": "VM112 · VM123 · consolas externas"}, + {"id": "bindings", "label": "APIs & Grupos", "type": "catalog", + "hint": "Bindings Odoo-style — grupos, roles, permissões"}, + {"id": "agents", "label": "Agentes IA", "type": "agents", + "hint": "A0–A7 — orquestração e aprovações"}, + ], + "scope_layers": [ + {"id": "vm122", "label": "Desk VM122", "host": "10.10.10.122", "desc": "Módulos internos"}, + {"id": "vm112", "label": "Onboard VM112", "host": "10.10.10.112", "desc": "Wizard, mail, API"}, + {"id": "vm123", "label": "Finance VM123", "host": "10.10.10.123", "desc": "FOSS, Odoo, OpenPanel"}, + {"id": "externas", "label": "Infra externa", "host": "—", "desc": "Cloudflare, Traefik, Proxmox…"}, + ], + "service_labels": { + "desk": "Desk VM122", + "vm112": "VM112 Wizard", + "vm123_foss": "FOSSBilling", + "vm123_odoo": "Odoo 16", + "vm123_openpanel": "OpenPanel", + "infra": "Infra / SSH", + "vm104": "Wazuh VM104", + }, + } diff --git a/projects/ops-desk/api/app/rbac_routes.py b/projects/ops-desk/api/app/rbac_routes.py new file mode 100644 index 0000000..4a635eb --- /dev/null +++ b/projects/ops-desk/api/app/rbac_routes.py @@ -0,0 +1,132 @@ +"""Rotas RBAC — Spec 027-UI / UI-C.""" + +from __future__ import annotations + +import os +import sqlite3 + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from app import agent_bindings, auth +from app.rbac_matrix import matrix_export + +router = APIRouter(prefix="/api/v1", tags=["rbac"]) + + +def _access_matrix_ui_enabled() -> bool: + return os.getenv("ACCESS_MATRIX_UI", "0").strip().lower() in ("1", "true", "yes", "on") + + +def _access_matrix_edit_enabled() -> bool: + return os.getenv("ACCESS_MATRIX_EDIT", "0").strip().lower() in ("1", "true", "yes", "on") + + +def _db(): + conn = auth.db() + try: + yield conn + finally: + conn.close() + + +def _require_matrix_view(user: auth.DeskUser) -> None: + if not _access_matrix_ui_enabled(): + raise HTTPException(404, "Matriz de acessos não activa neste ambiente") + if user.role != "super_admin": + raise HTTPException(403, "Matriz: apenas super_admin") + + +def _require_matrix_edit(user: auth.DeskUser) -> None: + _require_matrix_view(user) + if not _access_matrix_edit_enabled(): + raise HTTPException(403, "Edição da matriz desactivada neste ambiente") + + +@router.get("/config/features") +def config_features(user: auth.DeskUser = Depends(auth.get_current_user)): + """Feature flags expostas ao frontend (sem segredos).""" + enabled = _access_matrix_ui_enabled() + return { + "access_matrix_ui": enabled, + "access_matrix_preview": enabled and not _access_matrix_edit_enabled(), + "access_matrix_edit": enabled and _access_matrix_edit_enabled(), + } + + +@router.get("/rbac/matrix") +def rbac_matrix( + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), +): + """Matriz completa — Spec 027 (read-only ou editável).""" + _require_matrix_view(user) + return matrix_export(conn) + + +class AgentBindingPatch(BaseModel): + agent_id: str = Field(..., min_length=2, max_length=8) + role_id: str = Field(..., min_length=2, max_length=32) + relation: str = Field(..., pattern="^(ui|focus|approve)$") + enabled: bool + + +class GovernanceCapPatch(BaseModel): + cap_id: str = Field(..., min_length=3, max_length=32) + role_id: str = Field(..., min_length=2, max_length=32) + enabled: bool + + +@router.patch("/rbac/agent-bindings") +def patch_agent_binding( + body: AgentBindingPatch, + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), +): + """Ligar/desligar atribuição agente × função × relação.""" + _require_matrix_edit(user) + try: + result = agent_bindings.set_agent_binding( + conn, + agent_id=body.agent_id, + role_id=body.role_id, + relation=body.relation, + enabled=body.enabled, + username=user.username, + ) + conn.commit() + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return result + + +@router.patch("/rbac/agent-governance") +def patch_agent_governance( + body: GovernanceCapPatch, + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), +): + """Ligar/desligar capacidade global Agentics por função.""" + _require_matrix_edit(user) + try: + result = agent_bindings.set_governance_cap( + conn, + cap_id=body.cap_id, + role_id=body.role_id, + enabled=body.enabled, + username=user.username, + ) + conn.commit() + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + return result + + +@router.get("/rbac/agent-bindings/audit") +def agent_bindings_audit( + user: auth.DeskUser = Depends(auth.get_current_user), + conn: sqlite3.Connection = Depends(_db), + limit: int = Query(50, ge=1, le=200), +): + _require_matrix_view(user) + return {"entries": agent_bindings.list_audit(conn, limit=limit)} diff --git a/projects/ops-desk/api/tests/test_agents_034_kimi.py b/projects/ops-desk/api/tests/test_agents_034_kimi.py new file mode 100644 index 0000000..fcece98 --- /dev/null +++ b/projects/ops-desk/api/tests/test_agents_034_kimi.py @@ -0,0 +1,26 @@ +"""Tests Spec 034 — KIMI LLM provider.""" +from __future__ import annotations + +import os +from unittest.mock import patch + +from app.agents import llm_client + + +def test_resolve_provider_auto_kimi_when_key(): + with patch.dict(os.environ, {"AGENTIC_LLM_ENABLED": "true", "AGENTIC_LLM_PROVIDER": "auto", "KIMI_API_KEY": "sk-test"}, clear=False): + import importlib + importlib.reload(llm_client) + assert llm_client.resolve_provider() == "kimi" + + +def test_clean_env_strips_inline_comment(): + assert llm_client._clean_env("kimi-k2.5 # comment") == "kimi-k2.5" + assert llm_client._clean_env("https://api.moonshot.ai/v1 # internacional") == "https://api.moonshot.ai/v1" + + +def test_llm_status_includes_provider(): + status = llm_client.llm_status() + assert "provider" in status + assert "model" in status + assert "kimi_configured" in status diff --git a/projects/ops-desk/docker-compose.agentic-staging.yml b/projects/ops-desk/docker-compose.agentic-staging.yml index 174d307..c492798 100644 --- a/projects/ops-desk/docker-compose.agentic-staging.yml +++ b/projects/ops-desk/docker-compose.agentic-staging.yml @@ -17,6 +17,8 @@ services: SQLITE_PATH: /data/ops-staging.db REDIS_URL: redis://redis-staging:6379/0 OPS_API_URL: http://api-staging:8080 + ACCESS_MATRIX_UI: "1" + ACCESS_MATRIX_EDIT: "1" volumes: - /var/lib/ligbox-ops-platform-staging:/data - ./specs:/opt/ligbox-ops-platform/specs:ro diff --git a/projects/ops-desk/frontend/assets/access-matrix.css b/projects/ops-desk/frontend/assets/access-matrix.css new file mode 100644 index 0000000..8afc872 --- /dev/null +++ b/projects/ops-desk/frontend/assets/access-matrix.css @@ -0,0 +1,955 @@ +/* Spec 027-UI — Matriz de Acessos (enterprise preview) */ + +#access-matrix-content { + --am-surface: #fffdf9; + --am-surface-2: #f8f4ee; + --am-border: #ddd4c8; + --am-ink: #2a2520; + --am-muted: #6b6560; + --am-accent: #5c2e2e; + --am-accent-soft: #f3e8e8; + --am-desk: #5c2e2e; + --am-desk-bg: #f8eded; + --am-vm112: #1a6b5c; + --am-vm112-bg: #e8f5f1; + --am-vm123: #2a5298; + --am-vm123-bg: #eaf0fa; + --am-infra: #9a6b1a; + --am-infra-bg: #faf3e3; + --am-agent: #5a3d8a; + --am-agent-bg: #f2ebfa; + --am-full: #1a6648; + --am-full-bg: #dff0e8; + --am-read: #a05a18; + --am-read-bg: #faecd8; + --am-link: #2563b8; + --am-link-bg: #e3edfb; + --am-api: #5b3a9e; + --am-api-bg: #ede6fa; + --am-sys: #0f766e; + --am-sys-bg: #ddf5f2; + --am-none: #9a9590; + --am-none-bg: #f0ece6; + font-family: 'DM Sans', system-ui, sans-serif; +} + +#access-matrix-content .am-wrap { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +#access-matrix-content .am-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--am-border); +} + +#access-matrix-content .am-page-title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; + color: var(--am-ink); + letter-spacing: -0.02em; +} + +#access-matrix-content .am-page-sub { + margin: 0.25rem 0 0; + font-size: 0.82rem; + color: var(--am-muted); +} + +#access-matrix-content .am-preview-tag { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--am-accent); + border: 1px solid #d4a8a8; + padding: 0.35rem 0.65rem; + border-radius: 4px; + background: var(--am-accent-soft); + white-space: nowrap; +} + +#access-matrix-content .am-subnav { + display: flex; + gap: 0; + border: 1px solid var(--am-border); + border-radius: 8px; + overflow: hidden; + background: var(--am-surface-2); + width: fit-content; + max-width: 100%; + flex-wrap: wrap; +} + +#access-matrix-content .am-tab { + border: none; + border-right: 1px solid var(--am-border); + background: transparent; + padding: 0.55rem 1rem; + font: inherit; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + color: var(--am-muted); + transition: background 0.15s, color 0.15s; +} + +#access-matrix-content .am-tab:last-child { + border-right: none; +} + +#access-matrix-content .am-tab:hover { + background: rgba(92, 46, 46, 0.04); + color: var(--am-ink); +} + +#access-matrix-content .am-tab.active { + background: var(--am-accent-soft); + color: var(--am-accent); + font-weight: 600; + box-shadow: inset 0 -2px 0 var(--am-accent); +} + +#access-matrix-content .am-layout { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1.25rem; + align-items: start; +} + +#access-matrix-content .am-role-list { + position: sticky; + top: 0.5rem; + max-height: calc(100vh - 200px); + overflow: auto; + padding: 0.85rem; + background: linear-gradient(180deg, #fffdf9 0%, #faf6f0 100%); + border: 1px solid var(--am-border); + border-radius: 8px; + box-shadow: 0 1px 4px rgba(92, 46, 46, 0.04); +} + +#access-matrix-content .am-role-group { + margin-bottom: 1rem; +} + +#access-matrix-content .am-role-group h4 { + margin: 0 0 0.4rem; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--am-muted); + font-weight: 600; +} + +#access-matrix-content .am-role-btn { + display: block; + width: 100%; + text-align: left; + border: none; + border-left: 2px solid transparent; + background: transparent; + border-radius: 0 4px 4px 0; + padding: 0.4rem 0.5rem 0.4rem 0.65rem; + font: inherit; + font-size: 0.82rem; + cursor: pointer; + color: var(--am-ink); +} + +#access-matrix-content .am-role-btn:hover { + background: var(--am-surface-2); +} + +#access-matrix-content .am-role-btn.active { + background: #f3e8e8; + border-left-color: var(--am-accent); + font-weight: 600; + color: var(--am-accent); +} + +#access-matrix-content .am-panel { + background: var(--am-surface); + border: 1px solid var(--am-border); + border-radius: 8px; + overflow: hidden; +} + +#access-matrix-content .am-panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1.5rem; + padding: 1.1rem 1.25rem; + border-bottom: 1px solid var(--am-border); + background: linear-gradient(135deg, #faf6f0 0%, #f3ebe3 100%); +} + +#access-matrix-content .am-panel-kicker { + display: block; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--am-muted); + font-weight: 600; + margin-bottom: 0.2rem; +} + +#access-matrix-content .am-panel-head h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--am-ink); +} + +#access-matrix-content .am-panel-desc { + margin: 0.3rem 0 0; + font-size: 0.82rem; + color: var(--am-muted); + line-height: 1.45; +} + +#access-matrix-content .am-tab-hint { + margin: 0.45rem 0 0; + font-size: 0.75rem; + color: var(--am-accent); + font-weight: 500; +} + +#access-matrix-content .am-panel-body { + padding: 1.15rem 1.25rem 1.25rem; +} + +#access-matrix-content .am-legend { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.85rem; + font-size: 0.72rem; + color: var(--am-muted); + justify-content: flex-end; +} + +#access-matrix-content .am-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + white-space: nowrap; +} + +#access-matrix-content .am-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.4rem; + padding: 0.2rem 0.45rem; + border-radius: 3px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + font-family: ui-monospace, 'SF Mono', monospace; + border: 1px solid transparent; +} + +#access-matrix-content .am-badge--compact { + min-width: auto; + padding: 0.12rem 0.35rem; + font-size: 0.6rem; +} + +#access-matrix-content .am-badge--full { background: var(--am-full-bg); color: var(--am-full); border-color: #b8d4c4; } +#access-matrix-content .am-badge--read { background: var(--am-read-bg); color: var(--am-read); border-color: #e0c9a8; } +#access-matrix-content .am-badge--link { background: var(--am-link-bg); color: var(--am-link); border-color: #b8cce8; } +#access-matrix-content .am-badge--api { background: var(--am-api-bg); color: var(--am-api); border-color: #c8b8e0; } +#access-matrix-content .am-badge--system { background: var(--am-sys-bg); color: var(--am-sys); border-color: #a8d4d0; } +#access-matrix-content .am-badge--none { background: var(--am-none-bg); color: var(--am-none); border-color: #ddd8d0; } + +#access-matrix-content .am-section-lead { + margin: 0 0 1rem; + font-size: 0.82rem; + color: var(--am-muted); + line-height: 1.5; +} + +#access-matrix-content .am-block { + margin-bottom: 1.5rem; +} + +#access-matrix-content .am-block:last-child { + margin-bottom: 0; +} + +#access-matrix-content .am-block-title { + margin: 0 0 0.65rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; + color: var(--am-accent); + padding-left: 0.55rem; + border-left: 3px solid var(--am-accent); +} + +#access-matrix-content .am-empty { + margin: 0; + font-size: 0.82rem; + color: var(--am-muted); + font-style: italic; +} + +#access-matrix-content .am-scope-bar { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.65rem; + margin-bottom: 1.25rem; +} + +#access-matrix-content .am-scope-item { + padding: 0.65rem 0.75rem; + border: 1px solid var(--am-border); + border-radius: 6px; + border-left-width: 3px; +} + +#access-matrix-content .am-scope-item:nth-child(1) { + background: var(--am-desk-bg); + border-left-color: var(--am-desk); +} +#access-matrix-content .am-scope-item:nth-child(2) { + background: var(--am-vm112-bg); + border-left-color: var(--am-vm112); +} +#access-matrix-content .am-scope-item:nth-child(3) { + background: var(--am-vm123-bg); + border-left-color: var(--am-vm123); +} +#access-matrix-content .am-scope-item:nth-child(4) { + background: var(--am-infra-bg); + border-left-color: var(--am-infra); +} + +#access-matrix-content .am-scope-item:nth-child(1) .am-scope-host { color: var(--am-desk); } +#access-matrix-content .am-scope-item:nth-child(2) .am-scope-host { color: var(--am-vm112); } +#access-matrix-content .am-scope-item:nth-child(3) .am-scope-host { color: var(--am-vm123); } +#access-matrix-content .am-scope-item:nth-child(4) .am-scope-host { color: var(--am-infra); } + +#access-matrix-content .am-scope-label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: var(--am-ink); +} + +#access-matrix-content .am-scope-host { + display: block; + font-size: 0.68rem; + font-family: ui-monospace, monospace; + margin-top: 0.15rem; +} + +#access-matrix-content .am-scope-desc { + display: block; + font-size: 0.68rem; + color: var(--am-muted); + margin-top: 0.2rem; +} + +#access-matrix-content .am-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.65rem; + margin-bottom: 1.25rem; +} + +#access-matrix-content .am-stat { + padding: 0.75rem; + border: 1px solid var(--am-border); + border-radius: 6px; + text-align: center; + background: var(--am-surface); + border-top: 3px solid var(--am-border); +} + +#access-matrix-content .am-stat:nth-child(1) { border-top-color: var(--am-desk); background: var(--am-desk-bg); } +#access-matrix-content .am-stat:nth-child(2) { border-top-color: var(--am-vm112); background: var(--am-vm112-bg); } +#access-matrix-content .am-stat:nth-child(3) { border-top-color: var(--am-vm123); background: var(--am-vm123-bg); } +#access-matrix-content .am-stat:nth-child(4) { border-top-color: var(--am-agent); background: var(--am-agent-bg); } + +#access-matrix-content .am-stat:nth-child(1) .am-stat-n { color: var(--am-desk); } +#access-matrix-content .am-stat:nth-child(2) .am-stat-n { color: var(--am-vm112); } +#access-matrix-content .am-stat:nth-child(4) .am-stat-n { color: var(--am-agent); } + +#access-matrix-content .am-stat-n { + display: block; + font-size: 1.4rem; + font-weight: 700; + line-height: 1; +} + +#access-matrix-content .am-stat-l { + display: block; + font-size: 0.68rem; + color: var(--am-muted); + margin-top: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#access-matrix-content .am-desk-chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +#access-matrix-content .am-desk-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.55rem; + border: 1px solid #dcc8c8; + border-radius: 4px; + font-size: 0.78rem; + background: var(--am-desk-bg); + color: var(--am-ink); +} + +#access-matrix-content .am-sw-grid, +#access-matrix-content .am-api-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.65rem; +} + +#access-matrix-content .am-sw-card { + border: 1px solid var(--am-border); + border-radius: 6px; + padding: 0.75rem 0.85rem; + background: var(--am-surface); + border-left: 3px solid var(--am-vm112); + transition: box-shadow 0.15s; +} + +#access-matrix-content .am-api-card { + border: 1px solid var(--am-border); + border-radius: 6px; + padding: 0.75rem 0.85rem; + background: var(--am-surface); + border-left: 3px solid var(--am-vm123); + transition: box-shadow 0.15s; +} + +#access-matrix-content .am-api-card.am-svc-desk { border-left-color: var(--am-desk); background: linear-gradient(90deg, var(--am-desk-bg) 0%, var(--am-surface) 40%); } +#access-matrix-content .am-api-card.am-svc-vm112 { border-left-color: var(--am-vm112); background: linear-gradient(90deg, var(--am-vm112-bg) 0%, var(--am-surface) 40%); } +#access-matrix-content .am-api-card.am-svc-vm123_foss, +#access-matrix-content .am-api-card.am-svc-vm123_odoo, +#access-matrix-content .am-api-card.am-svc-vm123_openpanel { border-left-color: var(--am-vm123); background: linear-gradient(90deg, var(--am-vm123-bg) 0%, var(--am-surface) 40%); } +#access-matrix-content .am-api-card.am-svc-infra { border-left-color: var(--am-infra); background: linear-gradient(90deg, var(--am-infra-bg) 0%, var(--am-surface) 40%); } +#access-matrix-content .am-api-card.am-svc-vm104 { border-left-color: var(--am-agent); background: linear-gradient(90deg, var(--am-agent-bg) 0%, var(--am-surface) 40%); } + +#access-matrix-content .am-sw-card:hover, +#access-matrix-content .am-api-card:hover { + box-shadow: 0 2px 8px rgba(42, 37, 32, 0.06); +} + +#access-matrix-content .am-sw-card-head, +#access-matrix-content .am-api-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +#access-matrix-content .am-sw-card-head strong { + display: block; + font-size: 0.85rem; + color: var(--am-ink); +} + +#access-matrix-content .am-sw-product { + display: block; + font-size: 0.68rem; + color: var(--am-muted); + margin-top: 0.1rem; +} + +#access-matrix-content .am-sw-group { + margin: 0; + font-size: 0.72rem; + color: var(--am-muted); +} + +#access-matrix-content .am-sw-host { + display: block; + margin-top: 0.35rem; + font-size: 0.68rem; + color: var(--am-accent); +} + +#access-matrix-content .am-api-service { + font-size: 0.82rem; + font-weight: 600; + color: var(--am-ink); +} + +#access-matrix-content .am-api-type { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--am-muted); + margin-bottom: 0.35rem; +} + +#access-matrix-content .am-api-value { + display: block; + font-size: 0.72rem; + word-break: break-all; + color: var(--am-ink); + background: var(--am-surface-2); + padding: 0.35rem 0.45rem; + border-radius: 4px; + border: 1px solid var(--am-border); +} + +#access-matrix-content .am-api-foot { + display: block; + margin-top: 0.4rem; + font-size: 0.65rem; + font-family: ui-monospace, monospace; + color: var(--am-muted); +} + +#access-matrix-content .am-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 1rem 0 0.85rem; +} + +#access-matrix-content .am-filter { + border: 1px solid var(--am-border); + background: var(--am-surface); + border-radius: 4px; + padding: 0.35rem 0.7rem; + font: inherit; + font-size: 0.75rem; + cursor: pointer; + color: var(--am-muted); +} + +#access-matrix-content .am-filter.active { + background: var(--am-accent); + border-color: var(--am-accent); + color: #fff; + font-weight: 600; +} + +#access-matrix-content .am-sw-section { + margin-bottom: 1.5rem; +} + +#access-matrix-content .am-sw-section-head h4 { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +#access-matrix-content .am-sw-meta { + font-size: 0.72rem; + color: var(--am-muted); + font-family: ui-monospace, monospace; +} + +#access-matrix-content .am-table-wrap { + overflow: auto; + max-height: calc(100vh - 320px); + border: 1px solid var(--am-border); + border-radius: 6px; + margin-top: 0.5rem; +} + +#access-matrix-content .am-matrix-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +#access-matrix-content .am-matrix-table th, +#access-matrix-content .am-matrix-table td { + border-bottom: 1px solid var(--am-border); + padding: 0.45rem 0.4rem; + text-align: center; + vertical-align: middle; +} + +#access-matrix-content .am-matrix-table th { + background: var(--am-surface-2); + font-weight: 600; + font-size: 0.68rem; + white-space: nowrap; + color: var(--am-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +#access-matrix-content .am-matrix-table th.am-col-active { + background: var(--am-accent-soft); + color: var(--am-accent); +} + +#access-matrix-content .am-matrix-table td.am-row-highlight { + background: rgba(92, 46, 46, 0.08); +} + +#access-matrix-content .am-matrix-table th.am-sticky, +#access-matrix-content .am-matrix-table td.am-sticky { + position: sticky; + left: 0; + z-index: 1; + background: var(--am-surface); + text-align: left; + min-width: 180px; +} + +#access-matrix-content .am-matrix-table th.am-sticky { + z-index: 2; + background: var(--am-surface-2); +} + +#access-matrix-content .am-row-title { + display: block; + font-weight: 500; + color: var(--am-ink); + font-size: 0.8rem; +} + +#access-matrix-content .am-row-code, +#access-matrix-content .am-row-sub { + display: block; + font-size: 0.65rem; + color: var(--am-muted); + margin-top: 0.15rem; +} + +#access-matrix-content .am-row-code { + font-family: ui-monospace, monospace; +} + +#access-matrix-content .am-agents-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 0.65rem; +} + +#access-matrix-content .am-agent-card { + padding: 0.85rem 1rem; + border: 1px solid var(--am-border); + border-radius: 6px; + background: var(--am-surface); + border-top: 3px solid #c8b8e0; +} + +#access-matrix-content .am-agent-card--hit { + border-color: var(--am-agent); + border-top-color: var(--am-agent); + background: var(--am-agent-bg); +} + +#access-matrix-content .am-agent-top { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 0.35rem; +} + +#access-matrix-content .am-agent-id { + font-size: 0.68rem; + font-weight: 700; + font-family: ui-monospace, monospace; + color: var(--am-agent); +} + +#access-matrix-content .am-agent-top strong { + font-size: 0.88rem; +} + +#access-matrix-content .am-agent-role { + margin: 0; + font-size: 0.8rem; + color: var(--am-muted); + line-height: 1.4; +} + +#access-matrix-content .am-agent-approval { + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px solid var(--am-border); +} + +#access-matrix-content .am-agent-approval span { + display: block; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--am-muted); + margin-bottom: 0.2rem; +} + +#access-matrix-content .am-agent-approval code { + font-size: 0.72rem; + color: var(--am-ink); +} + +#access-matrix-content .am-agent-codename { + display: block; + font-size: 0.65rem; + color: var(--am-muted); + font-family: ui-monospace, monospace; +} + +#access-matrix-content .am-agent-section { + margin-top: 0.55rem; +} + +#access-matrix-content .am-agent-lbl { + display: block; + font-size: 0.62rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--am-muted); + margin-bottom: 0.25rem; +} + +#access-matrix-content .am-agent-meta { + font-size: 0.72rem; + color: var(--am-muted); + line-height: 1.4; +} + +#access-matrix-content .am-role-chip { + display: inline-block; + font-size: 0.68rem; + padding: 0.15rem 0.4rem; + margin: 0 0.2rem 0.2rem 0; + border-radius: 3px; + border: 1px solid var(--am-border); + background: var(--am-surface-2); + color: var(--am-ink); +} + +#access-matrix-content .am-role-chip--sel { + background: var(--am-accent-soft); + border-color: var(--am-accent); + color: var(--am-accent); + font-weight: 600; +} + +#access-matrix-content .am-rel { + display: inline-block; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.12rem 0.35rem; + border-radius: 3px; + margin: 0.1rem; + text-transform: uppercase; +} + +#access-matrix-content .am-rel--approve { background: #fce8e8; color: #8b2e2e; border: 1px solid #e8b8b8; } +#access-matrix-content .am-rel--focus { background: var(--am-vm123-bg); color: var(--am-vm123); border: 1px solid #b8cce8; } +#access-matrix-content .am-rel--ui { background: var(--am-vm112-bg); color: var(--am-vm112); border: 1px solid #a8d4c8; } +#access-matrix-content .am-rel--none { background: var(--am-none-bg); color: var(--am-none); border: 1px solid var(--am-border); } + +#access-matrix-content .am-rel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.15rem; + margin: 0.45rem 0; +} + +#access-matrix-content .am-rel-row--you .am-you { + font-size: 0.62rem; + color: var(--am-accent); + font-weight: 600; + margin-left: 0.25rem; +} + +#access-matrix-content .am-cap-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.65rem; +} + +#access-matrix-content .am-cap-chip { + font-size: 0.72rem; + padding: 0.3rem 0.55rem; + border-radius: 4px; + background: var(--am-agent-bg); + border: 1px solid #c8b8e0; + color: var(--am-agent); +} + +#access-matrix-content .am-agent-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin-bottom: 0.65rem; + font-size: 0.72rem; + color: var(--am-muted); +} + +#access-matrix-content .am-agent-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +#access-matrix-content .am-agent-mini-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.5rem; +} + +#access-matrix-content .am-agent-mini { + padding: 0.55rem 0.65rem; + border: 1px solid var(--am-border); + border-radius: 6px; + background: var(--am-surface); + border-left: 3px solid #c8b8e0; +} + +#access-matrix-content .am-agent-mini--approve { + border-left-color: var(--am-accent); + background: #faf6f6; +} + +#access-matrix-content .am-agent-mini header { + display: flex; + align-items: baseline; + gap: 0.35rem; + margin-bottom: 0.2rem; +} + +#access-matrix-content .am-agent-mini strong { + font-size: 0.82rem; +} + +#access-matrix-content .am-cell-toggles { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: center; + padding: 0.35rem 0.25rem !important; +} + +#access-matrix-content .am-toggle { + display: block; + width: 2.1rem; + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 0.18rem 0; + border-radius: 3px; + border: 1px solid var(--am-border); + background: var(--am-none-bg); + color: var(--am-muted); + cursor: pointer; + font-family: ui-monospace, monospace; +} + +#access-matrix-content .am-toggle.on { + background: var(--am-vm112-bg); + border-color: var(--am-vm112); + color: var(--am-vm112); +} + +#access-matrix-content .am-toggle.approve.on { + background: #fce8e8; + border-color: var(--am-accent); + color: var(--am-accent); +} + +#access-matrix-content .am-toggle.locked { + opacity: 0.55; + cursor: not-allowed; +} + +#access-matrix-content .am-cap-toggles { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 0.65rem; +} + +#access-matrix-content .am-cap-toggle { + font-size: 0.72rem; + padding: 0.35rem 0.6rem; + border-radius: 4px; + border: 1px solid var(--am-border); + background: var(--am-surface); + color: var(--am-muted); + cursor: pointer; + font: inherit; +} + +#access-matrix-content .am-cap-toggle.on { + background: var(--am-agent-bg); + border-color: var(--am-agent); + color: var(--am-agent); + font-weight: 600; +} + +#access-matrix-content .am-cap-toggle.locked { + opacity: 0.55; + cursor: not-allowed; +} + +#access-matrix-content .am-save-error { + margin: 0 0 0.75rem; + padding: 0.5rem 0.65rem; + border-radius: 4px; + background: #fce8e8; + border: 1px solid #e8b8b8; + color: #8b2e2e; + font-size: 0.8rem; +} + +@media (max-width: 1100px) { + #access-matrix-content .am-scope-bar, + #access-matrix-content .am-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 960px) { + #access-matrix-content .am-layout { + grid-template-columns: 1fr; + } + #access-matrix-content .am-role-list { + position: static; + max-height: none; + } + #access-matrix-content .am-subnav { + width: 100%; + } + #access-matrix-content .am-tab { + flex: 1 1 auto; + text-align: center; + font-size: 0.72rem; + padding: 0.5rem 0.4rem; + } +} + +@media (max-width: 560px) { + #access-matrix-content .am-scope-bar, + #access-matrix-content .am-stats { + grid-template-columns: 1fr; + } +} diff --git a/projects/ops-desk/frontend/assets/access-matrix.js b/projects/ops-desk/frontend/assets/access-matrix.js new file mode 100644 index 0000000..a391639 --- /dev/null +++ b/projects/ops-desk/frontend/assets/access-matrix.js @@ -0,0 +1,651 @@ +(function () { + 'use strict'; + + const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); + + const LEVEL_SHORT = { + full: 'ADM', + read: 'READ', + link: 'LINK', + api: 'API', + system: 'SYS', + none: '—', + }; + + const state = { + data: null, + tab: 'overview', + selectedRole: 'super_admin', + softwareFilter: 'all', + saving: false, + saveError: null, + }; + + async function patchApi(path, body) { + const r = await fetchWithTimeout(`/api/v1${path}`, { + method: 'PATCH', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`); + return r.json(); + } + + function bindingMeta(agentId, roleId, relation) { + const b = state.data?.agent_bindings_matrix?.agents?.[agentId]?.[roleId]?.[relation]; + return { + enabled: !!b?.enabled, + locked: !!b?.locked, + }; + } + + function capMeta(capId, roleId) { + const c = state.data?.agent_bindings_matrix?.caps?.[capId]?.[roleId]; + return { enabled: !!c?.enabled, locked: !!c?.locked }; + } + + async function toggleBinding(agentId, roleId, relation) { + const meta = bindingMeta(agentId, roleId, relation); + if (meta.locked) return; + state.saving = true; + state.saveError = null; + try { + await patchApi('/rbac/agent-bindings', { + agent_id: agentId, + role_id: roleId, + relation, + enabled: !meta.enabled, + }); + state.data = await api('/rbac/matrix'); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function toggleCap(capId, roleId) { + const meta = capMeta(capId, roleId); + if (meta.locked) return; + state.saving = true; + state.saveError = null; + try { + await patchApi('/rbac/agent-governance', { + cap_id: capId, + role_id: roleId, + enabled: !meta.enabled, + }); + state.data = await api('/rbac/matrix'); + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } catch (e) { + state.saveError = e.message; + const root = document.getElementById('access-matrix-content'); + if (root) paint(root); + } finally { + state.saving = false; + } + } + + async function api(path) { + const r = await fetchWithTimeout(`/api/v1${path}`, { + headers: authHeaders({ 'Content-Type': 'application/json' }), + }); + if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`); + return r.json(); + } + + function levelMeta(level) { + return state.data.legend[level] || state.data.legend.none; + } + + function badgeHtml(level, opts = {}) { + const meta = levelMeta(level); + const short = LEVEL_SHORT[meta.access] || '—'; + const cls = opts.compact ? ' am-badge--compact' : ''; + return `${short}`; + } + + function serviceLabel(svc) { + return state.data.service_labels?.[svc] || svc; + } + + function rolesByCategory(catalog, categoryLabels) { + const groups = {}; + Object.values(catalog.roles || {}).forEach((role) => { + const cat = role.category || 'platform'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(role); + }); + const order = ['ops', 'commercial', 'business', 'platform', 'system']; + return order + .filter((c) => groups[c]?.length) + .map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] })); + } + + function roleDeskRows() { + const roleId = state.selectedRole; + return (state.data.desk_modules || []).map((row) => ({ + ...row, + level: row.levels[roleId] || 'none', + })).filter((r) => r.level !== 'none'); + } + + function renderRoleSidebar() { + const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {}); + return groups.map((g) => ` +
+

${esc(g.label)}

+ ${g.roles.map((r) => ` + + `).join('')} +
`).join(''); + } + + function renderScopeBar() { + return `
+ ${(state.data.scope_layers || []).map((l) => ` +
+ ${esc(l.label)} + ${esc(l.host)} + ${esc(l.desc)} +
`).join('')} +
`; + } + + function renderMatrixTable(rows, rowLabel, idKey) { + const { role_columns, legend } = state.data; + const cols = role_columns || []; + const head = cols.map((c) => { + const label = state.data.catalog.roles[c]?.label || c; + const hl = c === state.selectedRole ? ' am-col-active' : ''; + return `${esc(label.split(' ')[0])}`; + }).join(''); + + const body = rows.map((row) => { + const cells = cols.map((role) => { + const lv = row.levels[role] || 'none'; + const tdHl = role === state.selectedRole ? ' am-row-highlight' : ''; + return `${badgeHtml(lv)}`; + }).join(''); + const sub = row.product + ? `${esc(row.product)}` + : `${esc(row[idKey] || '')}`; + return ` + + ${esc(row.label)} + ${sub} + ${cells}`; + }).join(''); + + return ` +
+ + ${head} + ${body} +
${esc(rowLabel)}
+
`; + } + + function renderDeskMatrix() { + return ` +

Grelha completa — módulos internos do Desk (VM122). Use Visão da função para resumo ou Software & Infra para VM112/123.

+ ${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`; + } + + function renderBindingCards(bindings, title) { + if (!bindings.length) { + return `

${title ? esc(title) + ' — ' : ''}sem registos para esta função.

`; + } + const byService = {}; + bindings.forEach((b) => { + if (!byService[b.service]) byService[b.service] = []; + byService[b.service].push(b); + }); + return ` + ${title ? `

${esc(title)}

` : ''} +
+ ${Object.entries(byService).flatMap(([svc, items]) => + items.map((b) => ` +
+
+ ${esc(serviceLabel(svc))} + ${badgeHtml(b.access || 'full', { compact: true })} +
+
${esc(b.type || 'binding')}
+ ${esc(b.value)} + +
`) + ).join('')} +
`; + } + + function renderSoftwareCards(rows, title) { + if (!rows.length) { + return `

${esc(title || 'Software')} — sem acesso directo.

`; + } + return ` + ${title ? `

${esc(title)}

` : ''} +
+ ${rows.map((r) => { + const meta = levelMeta(r.level); + return `
+
+
+ ${esc(r.label)} + ${esc(r.product)} +
+ ${badgeHtml(r.level, { compact: true })} +
+

${esc(r.group)}

+ ${r.host && r.host !== '—' ? `${esc(r.host)}` : ''} +
`; + }).join('')} +
`; + } + + function renderDeskChips() { + const rows = roleDeskRows(); + if (!rows.length) { + return '

Sem módulos Desk activos para esta função.

'; + } + return `
+ ${rows.map((r) => ` + + ${esc(r.label)} + ${badgeHtml(r.level, { compact: true })} + `).join('')} +
`; + } + + function renderRoleStats(role) { + const sw = state.data.role_software_summaries?.[state.selectedRole] || []; + const bindings = role?.bindings || []; + const desk = roleDeskRows(); + return `
+
${desk.length}Módulos Desk
+
${sw.length}Software
+
${bindings.length}APIs / Grupos
+
${(state.data.role_agents_summaries?.[state.selectedRole] || []).length}Agentes
+
`; + } + + function renderRoleOverview() { + const role = state.data.catalog.roles[state.selectedRole]; + const sw = state.data.role_software_summaries?.[state.selectedRole] || []; + const bindings = role?.bindings || []; + + return ` + ${renderScopeBar()} + ${renderRoleStats(role)} +
+

Desk VM122 — módulos activos

+ ${renderDeskChips()} +
+
+ ${renderSoftwareCards(sw, 'Software & infra em escopo')} +
+
+ ${renderBindingCards(bindings, 'APIs, grupos Odoo e permissões')} +
+
+

Agentics — capacidades da função

+ ${renderRoleAgentCaps()} + ${renderRoleAgentsSummary('')} +
`; + } + + function renderSoftwareMatrix() { + const { software_groups } = state.data; + const groups = (software_groups || []).filter((g) => + state.softwareFilter === 'all' || g.id === state.softwareFilter + ); + + const filters = [ + { id: 'all', label: 'Todas as camadas' }, + ...(software_groups || []).map((g) => ({ id: g.id, label: g.label.split('—')[0].trim() })), + ]; + + const filterBar = ` +
+ ${filters.map((f) => ` + `).join('')} +
`; + + const sections = groups.map((grp) => { + const meta = [grp.host, grp.url].filter(Boolean).join(' · '); + return ` +
+
+
+

${esc(grp.label)}

+ ${meta ? `${esc(meta)}` : ''} +
+
+ ${renderMatrixTable(grp.items || [], 'Recurso', 'id')} +
`; + }).join(''); + + const sw = state.data.role_software_summaries?.[state.selectedRole] || []; + return ` +

Matriz de software fora do Desk — VM112 (onboard/mail), VM123 (finance/hosting) e consolas de infra.

+ ${renderSoftwareCards(sw, 'Resumo da função seleccionada')} + ${filterBar}${sections}`; + } + + function renderBindings() { + const role = state.data.catalog.roles[state.selectedRole]; + if (!role) return '

Função não encontrada

'; + return ` +

Bindings estilo Odoo — grupos, roles e permissões provisionados por função.

+ ${renderBindingCards(role.bindings || [], '')}`; + } + + function roleLabel(roleId) { + return state.data.catalog.roles[roleId]?.label || roleId; + } + + function renderRelationChip(rel) { + const labels = state.data.agent_relation_labels || { + approve: 'Aprova', + focus: 'Operador', + ui: 'UI', + }; + return `${esc(labels[rel] || rel)}`; + } + + function renderRoleChips(roleIds) { + return (roleIds || []).map((r) => + `${esc(roleLabel(r))}` + ).join(''); + } + + function renderAgentGovernanceLegend() { + const labels = state.data.agent_relation_labels || {}; + return `
+ ${Object.entries(labels).map(([k, v]) => + `${renderRelationChip(k)} ${esc(v)}` + ).join('')} +
`; + } + + function renderRoleAgentCaps() { + const capsDef = [ + ['use_ui', 'UI Agentics'], + ['trigger_runs', 'Disparar cenários'], + ['approve_runbooks', 'Aprovar runbooks'], + ['configure_models', 'Configurar LLM'], + ]; + const roleId = state.selectedRole; + if (!state.data.editable) { + const caps = state.data.role_agentic_caps?.[roleId] || []; + if (!caps.length) { + return '

Esta função não acede ao módulo Agentics.

'; + } + return `
+ ${caps.map((c) => `${esc(c.label)}`).join('')} +
`; + } + return `
+ ${capsDef.map(([id, label]) => { + const meta = capMeta(id, roleId); + return ``; + }).join('')} +
`; + } + + function renderRoleAgentsSummary(title) { + const rows = state.data.role_agents_summaries?.[state.selectedRole] || []; + if (!rows.length) { + return `

${esc(title || 'Agentes')} — sem interacção directa.

`; + } + return ` + ${title ? `

${esc(title)}

` : ''} +
+ ${rows.map((a) => ` +
+
+ ${esc(a.id)} + ${esc(a.name)} +
+

${esc(a.role)}

+
${(a.relations || []).map(renderRelationChip).join('')}
+
`).join('')} +
`; + } + + function renderAgentCard(a) { + const rels = a.role_relations?.[state.selectedRole] || []; + const hit = rels.length > 0; + return ` +
+
+ ${esc(a.id)} +
+ ${esc(a.name)} + ${esc(a.codename)} +
+
+

${esc(a.role)}

+ ${hit ? `
${rels.map(renderRelationChip).join('')} esta função
` : ''} +
+ Aprova + ${renderRoleChips(a.approvers)} +
+
+ Operadores + ${renderRoleChips(a.operators)} +
+ ${(a.reads || []).length ? ` +
+ + ${esc(a.reads.slice(0, 3).join(' · '))} +
` : ''} + +
`; + } + + function renderRelationToggle(agentId, roleId, relation) { + const meta = bindingMeta(agentId, roleId, relation); + const short = { ui: 'UI', focus: 'OP', approve: 'AP' }[relation] || relation; + return ``; + } + + function renderAgentMatrixCell(agentId, role, rels) { + const tdHl = role === state.selectedRole ? ' am-row-highlight' : ''; + if (!state.data.editable) { + const inner = rels.length + ? rels.map(renderRelationChip).join('') + : ''; + return `${inner}`; + } + return ` + ${renderRelationToggle(agentId, role, 'ui')} + ${renderRelationToggle(agentId, role, 'focus')} + ${renderRelationToggle(agentId, role, 'approve')} + `; + } + + function renderAgentsMatrix() { + const agents = state.data.agents || []; + const cols = state.data.role_columns || []; + const head = cols.map((c) => { + const hl = c === state.selectedRole ? ' am-col-active' : ''; + return `${esc(roleLabel(c).split(' ')[0])}`; + }).join(''); + + const body = agents.map((a) => { + const cells = cols.map((role) => { + const rels = a.role_relations?.[role] || []; + return renderAgentMatrixCell(a.id, role, rels); + }).join(''); + return ` + + ${esc(a.id)} · ${esc(a.name)} + ${esc(a.role)} + ${cells}`; + }).join(''); + + return ` +

Matriz agente × função

+ ${renderAgentGovernanceLegend()} +
+ + ${head} + ${body} +
Agente
+
`; + } + + function renderAgents() { + const caps = state.data.role_agentic_caps?.[state.selectedRole] || []; + return ` +

+ Agentes A0–A7 usam conta agent_system. + ${state.data.editable + ? 'Clique UI / OP / AP para ligar ou desligar atribuições (audit activo).' + : 'Três relações: UI · Operador · Aprova.'} +

+ ${state.saveError ? `

${esc(state.saveError)}

` : ''} + ${(state.data.editable || caps.length) ? `

Capacidades — ${esc(roleLabel(state.selectedRole))}

${renderRoleAgentCaps()}
` : ''} + ${renderAgentsMatrix()} +
+

Detalhe por agente

+
+ ${(state.data.agents || []).map(renderAgentCard).join('')} +
+
`; + } + + function renderLegend() { + const legend = state.data.legend || {}; + return Object.entries(legend).map(([key, l]) => + `${badgeHtml(key, { compact: true })} ${esc(l.label)}` + ).join(''); + } + + function activeTabHint() { + const tab = (state.data.tabs || []).find((t) => t.id === state.tab); + return tab?.hint || ''; + } + + function renderMainPanel() { + const role = state.data.catalog.roles[state.selectedRole]; + let body = ''; + if (state.tab === 'overview') body = renderRoleOverview(); + else if (state.tab === 'vm122') body = renderDeskMatrix(); + else if (state.tab === 'software') body = renderSoftwareMatrix(); + else if (state.tab === 'bindings') body = renderBindings(); + else if (state.tab === 'agents') body = renderAgents(); + + return ` +
+
+
+ Spec 027 · RBAC +

${esc(role?.label || state.selectedRole)}

+

${esc(role?.description || '')}

+ ${activeTabHint() ? `

${esc(activeTabHint())}

` : ''} +
+
${renderLegend()}
+
+
${body}
+
`; + } + + function renderTabs() { + return (state.data.tabs || []).map((t) => + `` + ).join(''); + } + + function bindEvents(root) { + root.querySelectorAll('[data-am-role]').forEach((btn) => { + btn.addEventListener('click', () => { + state.selectedRole = btn.dataset.amRole; + paint(root); + }); + }); + root.querySelectorAll('[data-am-tab]').forEach((btn) => { + btn.addEventListener('click', () => { + state.tab = btn.dataset.amTab; + paint(root); + }); + }); + root.querySelectorAll('[data-am-sw-filter]').forEach((btn) => { + btn.addEventListener('click', () => { + state.softwareFilter = btn.dataset.amSwFilter; + paint(root); + }); + }); + root.querySelectorAll('[data-am-toggle]').forEach((btn) => { + btn.addEventListener('click', () => { + if (state.saving || !state.data?.editable) return; + const [agentId, roleId, relation] = btn.dataset.amToggle.split('|'); + toggleBinding(agentId, roleId, relation); + }); + }); + root.querySelectorAll('[data-am-cap]').forEach((btn) => { + btn.addEventListener('click', () => { + if (state.saving || !state.data?.editable) return; + const [capId, roleId] = btn.dataset.amCap.split('|'); + toggleCap(capId, roleId); + }); + }); + } + + function paint(root) { + root.innerHTML = ` +
+
+
+

Matriz de Acessos

+

Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa

+
+ ${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''} +
+ ${state.saveError ? `

${esc(state.saveError)}

` : ''} + +
+ + ${renderMainPanel()} +
+
`; + bindEvents(root); + } + + async function renderAccessMatrix() { + const root = document.getElementById('access-matrix-content'); + if (!root) return; + root.innerHTML = '

Carregando matriz de acessos…

'; + try { + state.data = await api('/rbac/matrix'); + if (!state.selectedRole && state.data.role_columns?.length) { + state.selectedRole = state.data.role_columns[0]; + } + paint(root); + } catch (e) { + root.innerHTML = `

Matriz indisponível: ${esc(e.message)}

`; + } + } + + window.renderAccessMatrix = renderAccessMatrix; + window.DeskAccessMatrix = { renderAccessMatrix }; +})(); diff --git a/projects/ops-desk/frontend/assets/agentic-ops.css b/projects/ops-desk/frontend/assets/agentic-ops.css index bed8652..54e609d 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.css +++ b/projects/ops-desk/frontend/assets/agentic-ops.css @@ -1,178 +1,1313 @@ -/* Spec 030 — Agentic Ops Mission Board */ -.ao-shell { - display: grid; - grid-template-columns: 200px 1fr minmax(300px, 360px); - gap: 1rem; - margin-top: 0.5rem; - align-items: start; +/* Spec 030 v2 — Ligbox Agent Squad (mission console, scoped dark) */ +#agentic-ops-content .ao-console { + min-height: 520px; + --ao-bg: #0a0e17; + --ao-surface: #111827; + --ao-surface-2: #1a2234; + --ao-border: rgba(99, 102, 241, 0.22); + --ao-border-strong: rgba(139, 92, 246, 0.45); + --ao-text: #eef2ff; + --ao-muted: #94a3b8; + --ao-indigo: #6366f1; + --ao-violet: #8b5cf6; + --ao-cyan: #22d3ee; + --ao-green: #34d399; + --ao-amber: #fbbf24; + --ao-red: #f87171; + --ao-orange: #fb923c; + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--ao-text); + background: + radial-gradient(ellipse 100% 60% at 50% -10%, rgba(99, 102, 241, 0.18), transparent 50%), + radial-gradient(ellipse 80% 50% at 100% 0%, rgba(139, 92, 246, 0.12), transparent 45%), + linear-gradient(180deg, #0d1220 0%, var(--ao-bg) 100%); + border: 1px solid var(--ao-border); + border-radius: 16px; + padding: 1.1rem 1.25rem 1.35rem; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05); } -.ao-status-bar { + +#agentic-ops-content .ao-mission-header { display: flex; flex-wrap: wrap; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 0.75rem; - padding: 0.75rem 1rem; - margin-bottom: 0.5rem; - border-radius: 8px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid var(--border, #333); + gap: 1rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--ao-border); } -.ao-status-metrics { - display: flex; - flex-wrap: wrap; - gap: 0.5rem 1rem; - font-size: 0.82rem; - color: var(--muted, #94a3b8); -} -.ao-fleet-rail { - position: sticky; - top: 0.5rem; -} -.ao-fleet-rail h3 { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted, #888); - margin-bottom: 0.5rem; -} -.ao-fleet-item { + +#agentic-ops-content .ao-mission-title { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.45rem 0.55rem; - margin-bottom: 0.25rem; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; - border: 1px solid transparent; -} -.ao-fleet-item:hover { background: rgba(255, 255, 255, 0.04); } -.ao-fleet-item--active { border-color: #3b82f6; background: rgba(59, 130, 246, 0.1); } -.ao-fleet-item--pulse .ao-fleet-dot { animation: ao-pulse 1.5s ease-in-out infinite; } -.ao-fleet-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #64748b; - flex-shrink: 0; -} -.ao-fleet-dot--active { background: #22c55e; } -@keyframes ao-pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(1.3); } -} -.ao-board { - display: grid; - grid-template-columns: repeat(4, minmax(160px, 1fr)); gap: 0.65rem; - min-height: 200px; } -.ao-board-col h4 { - font-size: 0.68rem; + +#agentic-ops-content .ao-mission-title h2 { + margin: 0; + font-size: 1.35rem; + font-weight: 800; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.08em; - margin: 0 0 0.5rem; - padding-bottom: 0.35rem; - border-bottom: 2px solid var(--border, #444); + background: linear-gradient(135deg, #e0e7ff 0%, #a5b4fc 50%, #c4b5fd 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } -.ao-board-col--critical h4 { border-color: #ef4444; color: #fca5a5; } -.ao-board-col--high h4 { border-color: #f97316; color: #fdba74; } -.ao-board-col--warn h4 { border-color: #eab308; color: #fde047; } -.ao-board-col--ok h4 { border-color: #64748b; color: #94a3b8; } -.ao-incident-card { - padding: 0.65rem 0.75rem; - margin-bottom: 0.5rem; - border-radius: 8px; - border: 1px solid var(--border, #333); - background: rgba(0, 0, 0, 0.15); - cursor: pointer; - min-height: 120px; + +#agentic-ops-content .ao-live-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--ao-green); + box-shadow: 0 0 12px rgba(52, 211, 153, 0.7); + animation: ao-live-pulse 2s ease-in-out infinite; +} + +#agentic-ops-content .ao-live-dot--warn { background: var(--ao-amber); box-shadow: 0 0 12px rgba(251, 191, 36, 0.6); } +#agentic-ops-content .ao-live-dot--alert { background: var(--ao-red); box-shadow: 0 0 14px rgba(248, 113, 113, 0.65); } + +@keyframes ao-live-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.15); opacity: 0.85; } +} + +#agentic-ops-content .ao-stat-chips { display: flex; - flex-direction: column; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.5rem; +} + +#agentic-ops-content .ao-chip { + display: inline-flex; + align-items: center; gap: 0.35rem; + padding: 0.28rem 0.65rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.04); + color: var(--ao-muted); } -.ao-incident-card:hover { border-color: #3b82f6; } -.ao-incident-card--active { - border-color: #3b82f6; - box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.4); - background: rgba(59, 130, 246, 0.08); + +#agentic-ops-content .ao-chip--tier { border-color: rgba(34, 211, 238, 0.35); color: #67e8f9; } +#agentic-ops-content .ao-chip--ok { border-color: rgba(52, 211, 153, 0.35); color: #6ee7b7; } +#agentic-ops-content .ao-chip--warn { border-color: rgba(251, 191, 36, 0.4); color: #fcd34d; } + +#agentic-ops-content .ao-btn-mission { + padding: 0.45rem 1rem; + border-radius: 8px; + border: 1px solid var(--ao-border-strong); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(139, 92, 246, 0.25)); + color: #e0e7ff; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; } -.ao-incident-title { font-weight: 600; font-size: 0.88rem; line-height: 1.3; } -.ao-incident-meta { font-size: 0.72rem; color: var(--muted, #888); } -.ao-incident-action { - font-size: 0.78rem; - color: var(--muted, #aaa); - flex: 1; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; + +#agentic-ops-content .ao-btn-mission:hover { + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(99, 102, 241, 0.35); +} + +#agentic-ops-content .ao-section-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--ao-muted); + margin: 0 0 0.65rem; +} + +#agentic-ops-content .ao-squad-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); + gap: 0.65rem; + margin-bottom: 1.35rem; +} + +#agentic-ops-content .ao-squad-card { + position: relative; + padding: 0.85rem 0.65rem 0.75rem; + border-radius: 12px; + border: 1px solid var(--ao-border); + background: var(--ao-surface); + text-align: center; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; overflow: hidden; } -.ao-incident-actions { display: flex; gap: 0.35rem; margin-top: auto; flex-wrap: wrap; } -.ao-context-panel { + +#agentic-ops-content .ao-squad-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.06) 0%, transparent 40%); + pointer-events: none; +} + +#agentic-ops-content .ao-squad-card:hover { + transform: translateY(-2px); + border-color: var(--ao-border-strong); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +#agentic-ops-content .ao-squad-card--selected { + border-color: var(--ao-violet); + box-shadow: 0 0 0 1px var(--ao-violet), 0 8px 28px rgba(139, 92, 246, 0.25); +} + +#agentic-ops-content .ao-squad-card--alert { + border-color: rgba(248, 113, 113, 0.45); + animation: ao-card-glow 2.5s ease-in-out infinite; +} + +@keyframes ao-card-glow { + 0%, 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.2); } + 50% { box-shadow: 0 0 20px rgba(248, 113, 113, 0.15); } +} + +#agentic-ops-content .ao-avatar { + width: 44px; + height: 44px; + margin: 0 auto 0.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; + border: 2px solid rgba(255, 255, 255, 0.12); + background: var(--ao-surface-2); + position: relative; +} + +#agentic-ops-content .ao-avatar-ring { + position: absolute; + inset: -3px; + border-radius: 50%; + border: 2px solid transparent; +} + +#agentic-ops-content .ao-squad-card--alert .ao-avatar-ring { + border-color: var(--ao-red); + animation: ao-ring-spin 4s linear infinite; +} + +@keyframes ao-ring-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +#agentic-ops-content .ao-squad-name { + font-size: 0.82rem; + font-weight: 700; + color: var(--ao-text); + line-height: 1.2; +} + +#agentic-ops-content .ao-squad-id { + font-size: 0.65rem; + color: var(--ao-muted); + margin-top: 0.15rem; +} + +#agentic-ops-content .ao-squad-status { + display: inline-block; + margin-top: 0.45rem; + padding: 0.12rem 0.45rem; + border-radius: 4px; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +#agentic-ops-content .ao-squad-status--standby { background: rgba(52, 211, 153, 0.15); color: #6ee7b7; } +#agentic-ops-content .ao-squad-status--active { background: rgba(251, 191, 36, 0.18); color: #fcd34d; } +#agentic-ops-content .ao-squad-status--alert { background: rgba(248, 113, 113, 0.2); color: #fca5a5; } + +#agentic-ops-content .ao-squad-badge { + position: absolute; + top: 6px; + right: 6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--ao-red); + color: #fff; + font-size: 0.62rem; + font-weight: 800; + display: flex; + align-items: center; + justify-content: center; +} + +#agentic-ops-content .ao-main { + display: grid; + grid-template-columns: 1fr minmax(300px, 340px); + gap: 1rem; + align-items: start; +} + +#agentic-ops-content .ao-kanban { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.6rem; + min-height: 220px; +} + +#agentic-ops-content .ao-kanban-col { + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + border: 1px solid var(--ao-border); + padding: 0.55rem; + min-height: 180px; +} + +#agentic-ops-content .ao-kanban-col h4 { + margin: 0 0 0.55rem; + padding: 0.35rem 0.5rem; + font-size: 0.65rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.1em; + border-radius: 6px; + text-align: center; +} + +#agentic-ops-content .ao-kanban-col--critical h4 { background: rgba(248, 113, 113, 0.2); color: #fca5a5; } +#agentic-ops-content .ao-kanban-col--high h4 { background: rgba(251, 146, 60, 0.2); color: #fdba74; } +#agentic-ops-content .ao-kanban-col--warn h4 { background: rgba(251, 191, 36, 0.15); color: #fde047; } +#agentic-ops-content .ao-kanban-col--info h4 { background: rgba(148, 163, 184, 0.12); color: #cbd5e1; } + +#agentic-ops-content .ao-mission-card { + padding: 0.65rem 0.7rem 0.65rem 0.85rem; + margin-bottom: 0.45rem; + border-radius: 8px; + background: var(--ao-surface); + border: 1px solid rgba(255, 255, 255, 0.06); + border-left: 3px solid var(--ao-indigo); + cursor: pointer; + transition: all 0.15s; +} + +#agentic-ops-content .ao-mission-card--critical { border-left-color: var(--ao-red); } +#agentic-ops-content .ao-mission-card--high { border-left-color: var(--ao-orange); } +#agentic-ops-content .ao-mission-card--warn { border-left-color: var(--ao-amber); } + +#agentic-ops-content .ao-mission-card:hover, +#agentic-ops-content .ao-mission-card--active { + border-color: var(--ao-violet); + background: rgba(139, 92, 246, 0.08); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +} + +#agentic-ops-content .ao-mission-card-title { + font-size: 0.8rem; + font-weight: 700; + line-height: 1.25; + margin-bottom: 0.3rem; +} + +#agentic-ops-content .ao-mission-card-meta { + font-size: 0.68rem; + color: var(--ao-muted); + margin-bottom: 0.35rem; +} + +#agentic-ops-content .ao-mission-card-action { + font-size: 0.72rem; + color: #c7d2fe; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 0.45rem; +} + +#agentic-ops-content .ao-mission-card-btns { + display: flex; + gap: 0.3rem; +} + +#agentic-ops-content .ao-mission-card-btns button { + padding: 0.2rem 0.5rem; + font-size: 0.65rem; + border-radius: 5px; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.05); + color: var(--ao-text); + cursor: pointer; +} + +#agentic-ops-content .ao-mission-card-btns button:hover { + background: rgba(99, 102, 241, 0.25); + border-color: var(--ao-indigo); +} + +#agentic-ops-content .ao-empty-state { + grid-column: 1 / -1; + text-align: center; + padding: 2.5rem 1rem; + color: var(--ao-muted); +} + +#agentic-ops-content .ao-empty-state .ao-empty-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; + opacity: 0.7; +} + +#agentic-ops-content .ao-comms { + background: var(--ao-surface); + border: 1px solid var(--ao-border); + border-radius: 12px; + padding: 0.85rem; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.55rem; min-height: 420px; position: sticky; top: 0.5rem; } -.ao-thread-messages { + +#agentic-ops-content .ao-comms-header { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ao-cyan); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--ao-border); +} + +#agentic-ops-content .ao-comms-feed { flex: 1; overflow-y: auto; - max-height: 280px; - padding: 0.5rem; - background: rgba(0, 0, 0, 0.12); - border-radius: 6px; - font-size: 0.85rem; -} -.ao-bubble { margin: 0.4rem 0; padding: 0.5rem 0.65rem; border-radius: 8px; } -.ao-bubble-agent { background: rgba(59, 130, 246, 0.12); border-left: 3px solid #3b82f6; } -.ao-bubble-human { background: rgba(34, 197, 94, 0.1); border-left: 3px solid #22c55e; } -.ao-bubble-meta { font-size: 0.7rem; color: var(--muted, #888); margin-bottom: 0.2rem; } -.ao-timeline { - margin-top: 1rem; - padding: 0.65rem; + max-height: 240px; + padding: 0.45rem; + background: rgba(0, 0, 0, 0.25); border-radius: 8px; - background: rgba(0, 0, 0, 0.1); - border: 1px solid var(--border, #333); + font-size: 0.8rem; } -.ao-timeline-row { + +#agentic-ops-content .ao-bubble { + margin: 0.35rem 0; + padding: 0.5rem 0.65rem; + border-radius: 8px; + line-height: 1.4; +} + +#agentic-ops-content .ao-bubble-agent { + background: rgba(99, 102, 241, 0.15); + border-left: 3px solid var(--ao-indigo); +} + +#agentic-ops-content .ao-bubble-human { + background: rgba(52, 211, 153, 0.1); + border-left: 3px solid var(--ao-green); +} + +#agentic-ops-content .ao-bubble-meta { + font-size: 0.65rem; + color: var(--ao-muted); + margin-bottom: 0.2rem; +} + +#agentic-ops-content .ao-comms textarea { + width: 100%; + padding: 0.5rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(0, 0, 0, 0.3); + color: var(--ao-text); + font-size: 0.8rem; + resize: vertical; + font-family: inherit; +} + +#agentic-ops-content .ao-comms textarea:focus { + outline: none; + border-color: var(--ao-violet); + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); +} + +#agentic-ops-content .ao-timeline-strip { + margin-top: 1rem; + padding: 0.65rem 0.85rem; + border-radius: 10px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--ao-border); +} + +#agentic-ops-content .ao-timeline-row { display: flex; justify-content: space-between; - font-size: 0.75rem; - padding: 0.25rem 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 0.72rem; + padding: 0.3rem 0; + color: var(--ao-muted); + border-bottom: 1px solid rgba(255, 255, 255, 0.04); } -.ao-mobile-tabs { display: none; } -.ao-empty { - text-align: center; - padding: 2rem 1rem; - color: var(--muted, #888); - grid-column: 1 / -1; + +#agentic-ops-content .ao-timeline-row:last-child { border-bottom: none; } + +#agentic-ops-content .ao-mobile-tabs { + display: none; + gap: 0.35rem; + margin-bottom: 0.75rem; } -.ao-skeleton { - height: 100px; + +#agentic-ops-content .ao-mobile-tabs button { + flex: 1; + padding: 0.4rem; border-radius: 8px; - background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); - background-size: 200% 100%; - animation: ao-shimmer 1.2s infinite; + border: 1px solid var(--ao-border); + background: var(--ao-surface); + color: var(--ao-muted); + font-size: 0.75rem; + cursor: pointer; } -@keyframes ao-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + +#agentic-ops-content .ao-mobile-tabs button.active { + border-color: var(--ao-violet); + color: var(--ao-text); + background: rgba(139, 92, 246, 0.15); } + +#agentic-ops-content .ao-pane { display: block; } +#agentic-ops-content .ao-loading { + text-align: center; + padding: 3rem; + color: var(--ao-muted); +} + @media (max-width: 1100px) { - .ao-shell { grid-template-columns: 1fr; } - .ao-mobile-tabs { - display: flex; - gap: 0.35rem; - margin-bottom: 0.75rem; - } - .ao-mobile-tabs button.active { background: rgba(59, 130, 246, 0.2); border-color: #3b82f6; } - .ao-pane { display: none; } - .ao-pane--active { display: block; } - .ao-fleet-rail, .ao-context-panel { position: static; } - .ao-board { grid-template-columns: 1fr 1fr; } + #agentic-ops-content .ao-main { grid-template-columns: 1fr; } + #agentic-ops-content .ao-kanban { grid-template-columns: 1fr 1fr; } + #agentic-ops-content .ao-comms { position: static; } + #agentic-ops-content .ao-mobile-tabs { display: flex; } + #agentic-ops-content .ao-pane:not(.ao-pane--active) { display: none; } + #agentic-ops-content .ao-squad-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } +} + +@media (max-width: 600px) { + #agentic-ops-content .ao-kanban { grid-template-columns: 1fr; } + #agentic-ops-content .ao-task-board { grid-template-columns: 1fr; } +} + +/* ── Content tabs (Mission Board / Task Board) ── */ +#agentic-ops-content .ao-content-tabs { + display: flex; + gap: 0.4rem; + margin-bottom: 0.85rem; +} + +#agentic-ops-content .ao-content-tabs button { + padding: 0.4rem 0.85rem; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.04); + color: var(--ao-muted); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} + +#agentic-ops-content .ao-content-tabs button.active { + border-color: var(--ao-violet); + color: var(--ao-text); + background: rgba(139, 92, 246, 0.18); +} + +#agentic-ops-content .ao-content-pane { display: none; } +#agentic-ops-content .ao-content-pane--active { display: block; } + +/* ── Task Board ── */ +#agentic-ops-content .ao-task-board { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; + min-height: 200px; +} + +#agentic-ops-content .ao-task-col { + background: rgba(0, 0, 0, 0.2); + border-radius: 10px; + border: 1px solid var(--ao-border); + padding: 0.55rem; + min-height: 160px; +} + +#agentic-ops-content .ao-task-col h4 { + margin: 0 0 0.55rem; + padding: 0.35rem 0.5rem; + font-size: 0.65rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.1em; + border-radius: 6px; + text-align: center; +} + +#agentic-ops-content .ao-task-col--wait h4 { background: rgba(251, 191, 36, 0.18); color: #fde047; } +#agentic-ops-content .ao-task-col--open h4 { background: rgba(99, 102, 241, 0.2); color: #c7d2fe; } +#agentic-ops-content .ao-task-col--new h4 { background: rgba(52, 211, 153, 0.15); color: #6ee7b7; } + +#agentic-ops-content .ao-task-card { + padding: 0.65rem 0.7rem; + margin-bottom: 0.45rem; + border-radius: 8px; + background: var(--ao-surface); + border: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: all 0.15s; +} + +#agentic-ops-content .ao-task-card:hover, +#agentic-ops-content .ao-task-card--active { + border-color: var(--ao-violet); + background: rgba(139, 92, 246, 0.08); +} + +#agentic-ops-content .ao-task-card-head { + display: flex; + align-items: center; + gap: 0.45rem; + margin-bottom: 0.35rem; +} + +#agentic-ops-content .ao-task-card-icon { + font-size: 1.2rem; + width: 28px; + text-align: center; +} + +#agentic-ops-content .ao-task-card-title { + font-size: 0.78rem; + font-weight: 700; + line-height: 1.25; + flex: 1; +} + +#agentic-ops-content .ao-task-card-meta { + font-size: 0.66rem; + color: var(--ao-muted); +} + +#agentic-ops-content .ao-task-badge { + display: inline-block; + margin-top: 0.35rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + background: rgba(251, 191, 36, 0.2); + color: #fcd34d; +} + +#agentic-ops-content .ao-new-chat-box { + padding: 0.65rem; + border-radius: 8px; + background: rgba(52, 211, 153, 0.08); + border: 1px dashed rgba(52, 211, 153, 0.35); +} + +#agentic-ops-content .ao-new-chat-box p { + margin: 0 0 0.5rem; + font-size: 0.72rem; + color: var(--ao-muted); + line-height: 1.4; +} + +#agentic-ops-content .ao-agent-picks { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.5rem; +} + +#agentic-ops-content .ao-agent-pick { + padding: 0.25rem 0.55rem; + border-radius: 999px; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.05); + color: var(--ao-text); + font-size: 0.68rem; + cursor: pointer; +} + +#agentic-ops-content .ao-agent-pick:hover, +#agentic-ops-content .ao-agent-pick--active { + border-color: var(--ao-violet); + background: rgba(139, 92, 246, 0.2); +} + +#agentic-ops-content .ao-comms-hint { + font-size: 0.72rem; + color: var(--ao-muted); + line-height: 1.45; + padding: 0.5rem 0.6rem; + border-radius: 8px; + background: rgba(99, 102, 241, 0.1); + border: 1px solid var(--ao-border); + margin-bottom: 0.5rem; +} + +#agentic-ops-content .ao-comms-hint strong { color: #c7d2fe; } + +#agentic-ops-content .ao-composer { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin-top: auto; + padding-top: 0.5rem; + border-top: 1px solid var(--ao-border); +} + +#agentic-ops-content .ao-composer label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ao-cyan); +} + +#agentic-ops-content .ao-composer-row { + display: flex; + gap: 0.4rem; +} + +#agentic-ops-content .ao-composer-row textarea { + flex: 1; +} + +#agentic-ops-content .ao-btn-send { + align-self: flex-end; + padding: 0.5rem 1rem; + border-radius: 8px; + border: none; + background: linear-gradient(135deg, var(--ao-indigo), var(--ao-violet)); + color: #fff; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + white-space: nowrap; +} + +#agentic-ops-content .ao-btn-send:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +#agentic-ops-content .ao-squad-card[data-agent-id]:not([data-agent-id="ALL"])::after { + content: '2× chat'; + position: absolute; + bottom: 4px; + left: 0; + right: 0; + font-size: 0.55rem; + color: var(--ao-muted); + opacity: 0; + transition: opacity 0.2s; +} + +#agentic-ops-content .ao-squad-card:hover::after { opacity: 0.7; } + +/* ── Subnav (Agentic Ops screens) ── */ +#agentic-ops-content .ao-subnav { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--ao-border); +} + +#agentic-ops-content .ao-subnav-btn { + padding: 0.45rem 0.85rem; + border-radius: 999px; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.04); + color: var(--ao-muted); + font-size: 0.74rem; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +#agentic-ops-content .ao-subnav-btn.active { + border-color: var(--ao-violet); + color: var(--ao-text); + background: rgba(139, 92, 246, 0.22); + box-shadow: 0 0 16px rgba(139, 92, 246, 0.15); +} + +#agentic-ops-content .ao-subview { display: none; } +#agentic-ops-content .ao-subview--active { display: block; } + +#agentic-ops-content .ao-header-actions { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: center; +} + +#agentic-ops-content .ao-main--full { + grid-template-columns: 1fr; +} + +#agentic-ops-content .ao-comms-compact { + margin-top: 0.85rem; + padding: 0.75rem 0.9rem; + border-radius: 10px; + border: 1px dashed var(--ao-border); + background: rgba(99, 102, 241, 0.08); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + font-size: 0.78rem; + color: var(--ao-muted); +} + +#agentic-ops-content .ao-task-board--full { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +#agentic-ops-content .ao-screen-head { + margin-bottom: 1rem; +} + +#agentic-ops-content .ao-screen-head h3 { + margin: 0 0 0.35rem; + font-size: 1.05rem; + font-weight: 800; + letter-spacing: 0.03em; +} + +/* ── Agent Management ── */ +#agentic-ops-content .ao-agent-mgmt-grid-wrap { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 0.85rem; +} + +#agentic-ops-content .ao-agent-mgmt-card { + padding: 1rem; + border-radius: 12px; + border: 1px solid var(--ao-border); + background: var(--ao-surface); +} + +#agentic-ops-content .ao-agent-mgmt-head { + display: flex; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +#agentic-ops-content .ao-agent-mgmt-head h3 { + margin: 0 0 0.25rem; + font-size: 0.95rem; +} + +#agentic-ops-content .ao-agent-mgmt-role { + margin: 0; + font-size: 0.78rem; + color: var(--ao-cyan); +} + +#agentic-ops-content .ao-avatar--lg { + width: 52px; + height: 52px; + font-size: 1.5rem; +} + +#agentic-ops-content .ao-agent-mgmt-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.65rem; + margin-bottom: 0.65rem; + font-size: 0.72rem; +} + +#agentic-ops-content .ao-agent-mgmt-list { + margin: 0.25rem 0 0; + padding-left: 1rem; + color: var(--ao-muted); +} + +#agentic-ops-content .ao-agent-mgmt-scenarios { + margin-bottom: 0.75rem; + font-size: 0.72rem; +} + +#agentic-ops-content .ao-btn-chat-agent { + width: 100%; +} + +#agentic-ops-content .ao-squad-chat-btn { + position: absolute; + top: 6px; + right: 6px; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(139, 92, 246, 0.25); + cursor: pointer; + font-size: 0.75rem; + opacity: 0; + transition: opacity 0.15s; +} + +#agentic-ops-content .ao-squad-card:hover .ao-squad-chat-btn, +#agentic-ops-content .ao-squad-card--selected .ao-squad-chat-btn { + opacity: 1; +} + +/* ── Knowledge Graph ── */ +#agentic-ops-content .ao-kg-search { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +#agentic-ops-content .ao-kg-search input { + flex: 1; + padding: 0.55rem 0.75rem; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(0, 0, 0, 0.3); + color: var(--ao-text); + font-size: 0.85rem; +} + +#agentic-ops-content .ao-kg-layout { + display: grid; + grid-template-columns: minmax(240px, 1fr) 2fr; + gap: 1rem; +} + +#agentic-ops-content .ao-kg-graph { + display: flex; + flex-direction: column; + gap: 0.45rem; + max-height: 480px; + overflow-y: auto; +} + +#agentic-ops-content .ao-kg-node { + padding: 0.55rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(99, 102, 241, 0.08); + font-size: 0.75rem; +} + +#agentic-ops-content .ao-kg-node-title { + font-weight: 700; + margin-bottom: 0.2rem; +} + +#agentic-ops-content .ao-kg-node-meta { + color: var(--ao-muted); + font-size: 0.68rem; +} + +#agentic-ops-content .ao-kg-hit { + padding: 0.65rem 0.75rem; + margin-bottom: 0.5rem; + border-radius: 8px; + border-left: 3px solid var(--ao-violet); + background: rgba(0, 0, 0, 0.25); + font-size: 0.78rem; +} + +#agentic-ops-content .ao-kg-hit-src { + font-size: 0.65rem; + color: var(--ao-cyan); + margin-bottom: 0.35rem; +} + +#agentic-ops-content .ao-kg-hit-snippet { + color: var(--ao-muted); + line-height: 1.45; +} + +/* ── Recurring Tasks ── */ +#agentic-ops-content .ao-recurring-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +#agentic-ops-content .ao-recurring-card { + padding: 0.85rem 1rem; + border-radius: 10px; + border: 1px solid var(--ao-border); + background: var(--ao-surface); +} + +#agentic-ops-content .ao-recurring-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.45rem; +} + +#agentic-ops-content .ao-recurring-head h4 { + margin: 0; + font-size: 0.88rem; +} + +#agentic-ops-content .ao-recurring-meta { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.35rem; +} + +/* ── Activity Feed ── */ +#agentic-ops-content .ao-activity-feed { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 620px; + overflow-y: auto; +} + +#agentic-ops-content .ao-activity-row { + display: grid; + grid-template-columns: 80px 1fr; + gap: 0.65rem; + padding: 0.55rem 0.65rem; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(0, 0, 0, 0.2); + font-size: 0.76rem; +} + +#agentic-ops-content .ao-activity-time { + color: var(--ao-muted); + font-size: 0.68rem; +} + +#agentic-ops-content .ao-activity-type { + font-weight: 700; + margin-bottom: 0.15rem; +} + +#agentic-ops-content .ao-activity-msg { + color: var(--ao-muted); +} + +/* ── Chat Modal ── */ +#agentic-ops-content .ao-modal { + position: fixed; + inset: 0; + z-index: 1200; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; +} + +#agentic-ops-content .ao-modal.ao-modal--open { + display: flex; +} + +body.ao-modal-open { + overflow: hidden; +} + +#agentic-ops-content .ao-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 6, 23, 0.78); + backdrop-filter: blur(4px); +} + +#agentic-ops-content .ao-modal-dialog { + position: relative; + z-index: 1; + width: min(920px, 96vw); + max-height: 92vh; + display: flex; + flex-direction: column; + border-radius: 16px; + border: 1px solid var(--ao-border-strong); + background: linear-gradient(180deg, #121a2e 0%, #0a0e17 100%); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55); + overflow: hidden; +} + +#agentic-ops-content .ao-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.15rem; + border-bottom: 1px solid var(--ao-border); + background: rgba(99, 102, 241, 0.1); +} + +#agentic-ops-content .ao-modal-agent { + display: flex; + align-items: center; + gap: 0.75rem; +} + +#agentic-ops-content .ao-modal-icon { + font-size: 2rem; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + background: rgba(139, 92, 246, 0.2); +} + +#agentic-ops-content .ao-modal-name { + font-size: 1rem; + font-weight: 800; +} + +#agentic-ops-content .ao-modal-sub { + font-size: 0.72rem; + color: var(--ao-muted); +} + +#agentic-ops-content .ao-modal-close { + width: 36px; + height: 36px; + border-radius: 10px; + border: 1px solid var(--ao-border); + background: rgba(255, 255, 255, 0.06); + color: var(--ao-text); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; +} + +#agentic-ops-content .ao-modal-context { + padding: 0.65rem 1.15rem; + font-size: 0.78rem; + color: var(--ao-muted); + border-bottom: 1px solid var(--ao-border); + min-height: 2rem; +} + +#agentic-ops-content .ao-modal-feed { + flex: 1; + min-height: 320px; + max-height: 52vh; + overflow-y: auto; + padding: 1rem 1.15rem; + background: rgba(0, 0, 0, 0.25); +} + +#agentic-ops-content .ao-modal-composer { + padding: 1rem 1.15rem 1.15rem; + border-top: 1px solid var(--ao-border); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#agentic-ops-content .ao-modal-composer label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ao-cyan); +} + +#agentic-ops-content .ao-modal-composer textarea { + width: 100%; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--ao-border); + background: rgba(0, 0, 0, 0.35); + color: var(--ao-text); + font-size: 0.88rem; + resize: vertical; + min-height: 88px; + font-family: inherit; +} + +#agentic-ops-content .ao-modal-actions { + display: flex; + justify-content: flex-end; +} + +@media (max-width: 900px) { + #agentic-ops-content .ao-kg-layout { grid-template-columns: 1fr; } + #agentic-ops-content .ao-agent-mgmt-grid { grid-template-columns: 1fr; } + #agentic-ops-content .ao-subnav-btn { font-size: 0.68rem; padding: 0.35rem 0.6rem; } +} + +@media (max-width: 600px) { + #agentic-ops-content .ao-modal-dialog { width: 100%; max-height: 96vh; } + #agentic-ops-content .ao-modal-feed { max-height: 45vh; min-height: 240px; } +} + +/* ── Chat markdown + streaming ── */ +#agentic-ops-content .ao-bubble-body { + line-height: 1.55; +} + +#agentic-ops-content .ao-md-table { + width: 100%; + border-collapse: collapse; + margin: 0.5rem 0 0.75rem; + font-size: 0.76rem; +} + +#agentic-ops-content .ao-md-table th, +#agentic-ops-content .ao-md-table td { + border: 1px solid var(--ao-border); + padding: 0.35rem 0.5rem; + text-align: left; + vertical-align: top; +} + +#agentic-ops-content .ao-md-table th { + background: rgba(99, 102, 241, 0.15); + color: #c7d2fe; + font-weight: 700; +} + +#agentic-ops-content .ao-md-hr { + border: none; + border-top: 1px solid var(--ao-border); + margin: 0.75rem 0; +} + +#agentic-ops-content .ao-bubble-agent .ao-md-h { + color: #e0e7ff; + border-left: 3px solid var(--ao-violet); + padding-left: 0.5rem; +} + + +#agentic-ops-content .ao-md-p { + margin: 0 0 0.45rem; +} + +#agentic-ops-content .ao-md-h { + margin: 0.5rem 0 0.35rem; + font-size: 0.92rem; +} + +#agentic-ops-content .ao-md-ul { + margin: 0.25rem 0 0.55rem 1.1rem; + padding: 0; +} + +#agentic-ops-content .ao-md-ul li { + margin-bottom: 0.25rem; +} + +#agentic-ops-content .ao-md-code { + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: rgba(0, 0, 0, 0.35); + font-family: ui-monospace, monospace; + font-size: 0.85em; +} + +#agentic-ops-content .ao-bubble--typing .ao-stream-body { + min-height: 1.2rem; +} + +#agentic-ops-content .ao-typing-dots span { + animation: ao-dot 1.2s infinite; + opacity: 0.3; +} + +#agentic-ops-content .ao-typing-dots span:nth-child(2) { animation-delay: 0.2s; } +#agentic-ops-content .ao-typing-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes ao-dot { + 0%, 80%, 100% { opacity: 0.25; } + 40% { opacity: 1; } +} + +#agentic-ops-content .ao-modal-feed { + scroll-behavior: smooth; +} + +#agentic-ops-content .ao-action-bar { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin-top: 0.65rem; + padding-top: 0.55rem; + border-top: 1px dashed var(--ao-border); +} + +#agentic-ops-content .ao-action-bar-label { + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ao-cyan); + width: 100%; + margin-bottom: 0.15rem; +} + +#agentic-ops-content .ao-action-btn { + padding: 0.35rem 0.65rem; + border-radius: 8px; + border: 1px solid var(--ao-border); + background: rgba(99, 102, 241, 0.12); + color: var(--ao-text); + font-size: 0.72rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +#agentic-ops-content .ao-action-btn:hover:not(:disabled) { + border-color: var(--ao-violet); + background: rgba(139, 92, 246, 0.22); +} + +#agentic-ops-content .ao-action-btn:disabled { + opacity: 0.55; + cursor: wait; +} + +#agentic-ops-content .ao-action-btn--run.ao-action-btn--critical, +#agentic-ops-content .ao-action-btn--run.ao-action-btn--high { + border-color: rgba(248, 113, 113, 0.45); + background: rgba(248, 113, 113, 0.12); +} + +#agentic-ops-content .ao-action-btn--run.ao-action-btn--warn { + border-color: rgba(251, 191, 36, 0.45); + background: rgba(251, 191, 36, 0.1); +} + +#agentic-ops-content .ao-action-btn--ack { + border-color: rgba(52, 211, 153, 0.35); + background: rgba(52, 211, 153, 0.08); +} + +#agentic-ops-content .ao-bubble-system { + background: rgba(34, 211, 238, 0.08); + border-left: 3px solid var(--ao-cyan); + font-size: 0.78rem; } diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js index 28427ab..334f6ac 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.js +++ b/projects/ops-desk/frontend/assets/agentic-ops.js @@ -1,30 +1,55 @@ (function () { const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); const SEV_COLS = [ - { key: 'critical', label: 'Crítico', cls: 'ao-board-col--critical' }, - { key: 'high', label: 'Alto', cls: 'ao-board-col--high' }, - { key: 'warn', label: 'Aviso', cls: 'ao-board-col--warn' }, - { key: 'info', label: 'Info / OK', cls: 'ao-board-col--ok' }, + { key: 'critical', label: 'Crítico', cls: 'ao-kanban-col--critical' }, + { key: 'high', label: 'Alto', cls: 'ao-kanban-col--high' }, + { key: 'warn', label: 'Aviso', cls: 'ao-kanban-col--warn' }, + { key: 'info', label: 'Info / OK', cls: 'ao-kanban-col--info' }, ]; - const AGENT_ACCENTS = { + const AGENT_ICONS = { + A0: '🎯', A1: '💓', A2: '🛤', A3: '✉', A4: '🛡', A5: '👁', + A6: '🤖', A7: '🔧', sentinel: '📡', curator: '📚', + }; + const AGENT_COLORS = { A0: '#6366f1', A1: '#22c55e', A2: '#3b82f6', A3: '#06b6d4', A4: '#8b5cf6', A5: '#ec4899', A6: '#a855f7', A7: '#ef4444', sentinel: '#f59e0b', curator: '#64748b', }; + const SUB_VIEWS = [ + { id: 'command', label: 'Squad Command', icon: '🎯' }, + { id: 'agents', label: 'Agent Management', icon: '👥' }, + { id: 'tasks', label: 'Task Board', icon: '📋' }, + { id: 'memory', label: 'Knowledge Graph', icon: '🧠' }, + { id: 'recurring', label: 'Recurring Tasks', icon: '🔄' }, + { id: 'activity', label: 'Activity Feed', icon: '📡' }, + ]; let state = { selectedAgent: 'A6', selectedIncidentId: null, threadId: null, mobileTab: 'board', + mainTab: 'missions', + subView: 'command', + chatModalOpen: false, + kbQuery: '', + lastActions: [], pollTimer: null, }; + let cache = null; + let extraCache = { scenarios: null, activity: null, kbSources: null, kbHits: null }; + let shellMounted = false; + let eventsBound = false; + let renderInFlight = false; + let pendingRender = null; + let lastThreadHtml = ''; + let chatInFlight = false; async function agentsApi(path, opts = {}) { - const deskApi = typeof globalThis.api === 'function' ? globalThis.api : null; - if (deskApi) return deskApi(`/v1/agents${path}`, opts); const h = authHeaders({ ...(opts.headers || {}) }); if (!(opts.body instanceof FormData) && !h['Content-Type']) h['Content-Type'] = 'application/json'; - const r = await fetchWithTimeout(`/api/v1/agents${path}`, { ...opts, headers: h }, 60000); + const isSlow = /\/chat$/.test(path) || path.includes('/chat'); + const timeoutMs = isSlow ? 180000 : 60000; + const r = await fetchWithTimeout(`/api/v1/agents${path}`, { ...opts, headers: h }, timeoutMs); if (r.status === 401) { logout(); throw new Error('sessão expirada — faça login novamente'); @@ -33,6 +58,99 @@ return r.json(); } + function escHtml(s) { + return esc(s); + } + + function renderMarkdown(text) { + if (!text) return ''; + const lines = String(text).split('\n'); + const out = []; + let inList = false; + let inTable = false; + const flushList = () => { + if (inList) { out.push(''); inList = false; } + }; + const flushTable = () => { + if (inTable) { out.push(''); inTable = false; } + }; + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const line = raw.trimEnd(); + if (/^\|.+\|$/.test(line.trim())) { + flushList(); + const cells = line.trim().slice(1, -1).split('|').map((c) => c.trim()); + if (/^[-:\s|]+$/.test(line.replace(/\|/g, ''))) continue; + if (!inTable) { + out.push(''); + cells.forEach((c) => out.push(``)); + out.push(''); + inTable = true; + } else { + out.push(''); + cells.forEach((c) => out.push(``)); + out.push(''); + } + continue; + } + flushTable(); + if (/^---+$/.test(line.trim())) { + flushList(); + out.push('
'); + } else if (/^### (.+)/.test(line)) { + flushList(); + out.push(`

${inlineMd(line.replace(/^### /, ''))}

`); + } else if (/^## (.+)/.test(line)) { + flushList(); + out.push(`

${inlineMd(line.replace(/^## /, ''))}

`); + } else if (/^[-*] (.+)/.test(line)) { + if (!inList) { out.push('
${inlineMd(c)}
${inlineMd(c)}