Implement Spec 027 access matrix preview and agent bindings enforcement.
Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit, and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
This commit is contained in:
parent
9c443f32a9
commit
6c4b063f76
20 changed files with 6256 additions and 447 deletions
358
projects/ops-desk/api/app/agent_bindings.py
Normal file
358
projects/ops-desk/api/app/agent_bindings.py
Normal file
|
|
@ -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}
|
||||||
240
projects/ops-desk/api/app/agents/chat_context_builder.py
Normal file
240
projects/ops-desk/api/app/agents/chat_context_builder.py
Normal file
|
|
@ -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]
|
||||||
137
projects/ops-desk/api/app/agents/chat_stream.py
Normal file
137
projects/ops-desk/api/app/agents/chat_stream.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
import httpx
|
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_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
|
||||||
AGENTIC_EMBED_MODEL = os.getenv("AGENTIC_EMBED_MODEL", "nomic-embed-text")
|
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")
|
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:
|
def ollama_available() -> bool:
|
||||||
|
|
@ -19,13 +76,80 @@ def ollama_available() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _chat(prompt: str, *, system: str | None = None, max_tokens: int = 800) -> tuple[str, str]:
|
def _openai_compatible_chat(
|
||||||
if not AGENTIC_LLM_ENABLED or not ollama_available():
|
*,
|
||||||
return ("", "t0")
|
base_url: str,
|
||||||
messages = []
|
api_key: str,
|
||||||
if system:
|
model: str,
|
||||||
messages.append({"role": "system", "content": system})
|
messages: list[dict],
|
||||||
messages.append({"role": "user", "content": prompt})
|
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:
|
try:
|
||||||
with httpx.Client(timeout=120.0) as c:
|
with httpx.Client(timeout=120.0) as c:
|
||||||
r = c.post(
|
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
|
return txt, AGENTIC_LLM_MODEL
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
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(
|
def advise_human_action(
|
||||||
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
|
*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
prompt = (
|
kb = "---".join(kb_snippets or [])[:2500] or "N/A"
|
||||||
"Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n"
|
messages = [
|
||||||
f"Problema: {finding_title}\nDetalhe: {finding_detail}\n"
|
{
|
||||||
f"KB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
|
"role": "system",
|
||||||
)
|
"content": "Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?",
|
||||||
txt, model = _chat(prompt)
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {kb}",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
txt, model = _llm_chat(messages, max_tokens=400)
|
||||||
if txt:
|
if txt:
|
||||||
return txt, model
|
return txt, model
|
||||||
return (f"Investigar manualmente: {finding_title}", "t0")
|
return (f"Investigar manualmente: {finding_title}", "t0")
|
||||||
|
|
@ -60,27 +407,43 @@ def chat_context(
|
||||||
question: str,
|
question: str,
|
||||||
kb_snippets: list[str] | None = None,
|
kb_snippets: list[str] | None = None,
|
||||||
findings_summary: str | None = None,
|
findings_summary: str | None = None,
|
||||||
|
ops_context: str | None = None,
|
||||||
user_role: str = "technician",
|
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]:
|
) -> tuple[str, str]:
|
||||||
"""T1 — resposta contextual para janela Desk / bot interno."""
|
# Legacy string history → single user turn if no structured history
|
||||||
system = (
|
hist_msgs = list(history_messages or [])
|
||||||
"És o copiloto Agentic Ops da Ligbox (VM112 wizard, VM122 Desk, VM123 finance). "
|
if not hist_msgs and history:
|
||||||
"Responde em português BR, objectivo, com passos acionáveis. "
|
hist_msgs = [{"role": "user", "content": f"[Histórico resumido]\n{history[:3000]}"}]
|
||||||
"Nunca inventes credenciais. Se não souberes, diz o que verificar."
|
|
||||||
|
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 = []
|
txt, model = _llm_chat(messages, max_tokens=1200)
|
||||||
if findings_summary:
|
|
||||||
ctx.append(f"Findings abertos:\n{findings_summary[:2000]}")
|
|
||||||
if kb_snippets:
|
|
||||||
ctx.append("KB:\n" + "\n---\n".join(kb_snippets[:6])[:4000])
|
|
||||||
prompt = (
|
|
||||||
f"Perfil utilizador: {user_role}\n"
|
|
||||||
f"Contexto ops:\n{chr(10).join(ctx) or 'N/A'}\n\n"
|
|
||||||
f"Pergunta: {question}"
|
|
||||||
)
|
|
||||||
txt, model = _chat(prompt, system=system)
|
|
||||||
if txt:
|
if txt:
|
||||||
return txt, model
|
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 (
|
return (
|
||||||
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
|
"Modo T0 activo — LLM indisponível. Consulte findings e audit log no painel Agentic Ops.",
|
||||||
"t0",
|
"t0",
|
||||||
|
|
|
||||||
|
|
@ -275,17 +275,6 @@ def human_reply(
|
||||||
requires_human=False,
|
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
|
return mid
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,15 @@ import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
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 llm_client, runner, store
|
||||||
from app.agents import messages as agent_messages
|
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"])
|
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
|
||||||
|
|
||||||
|
|
@ -23,20 +26,17 @@ def _db():
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _ops_view(user):
|
def _ops_view(user, conn):
|
||||||
if user.role not in (
|
if not agent_bindings.can_use_agentics_ui(conn, user.role):
|
||||||
"super_admin",
|
|
||||||
"ops_lead",
|
|
||||||
"technician",
|
|
||||||
"noc",
|
|
||||||
"agentic_operator",
|
|
||||||
"developer",
|
|
||||||
"devops",
|
|
||||||
"security_analyst",
|
|
||||||
):
|
|
||||||
raise HTTPException(403, "insufficient permissions")
|
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):
|
class ChatRequest(BaseModel):
|
||||||
question: str = Field(..., min_length=2, max_length=4000)
|
question: str = Field(..., min_length=2, max_length=4000)
|
||||||
include_findings: bool = True
|
include_findings: bool = True
|
||||||
|
|
@ -50,19 +50,12 @@ class ReplyRequest(BaseModel):
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
def agents_health():
|
def agents_health():
|
||||||
return {
|
return {"status": "ok", **llm_client.llm_status()}
|
||||||
"status": "ok",
|
|
||||||
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
|
|
||||||
"ollama": llm_client.ollama_available(),
|
|
||||||
"ollama_url": llm_client.OLLAMA_BASE_URL,
|
|
||||||
"model": llm_client.AGENTIC_LLM_MODEL,
|
|
||||||
"embed_model": llm_client.AGENTIC_EMBED_MODEL,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
@router.get("/overview")
|
||||||
def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
_ops_view(user)
|
_ops_view(user, conn)
|
||||||
return store.get_overview(conn)
|
return store.get_overview(conn)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,13 +68,13 @@ def agents_incidents(
|
||||||
agent_id: str | None = None,
|
agent_id: str | None = None,
|
||||||
limit: int = Query(50, ge=1, le=200),
|
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)}
|
return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/incidents/{incident_id}")
|
@router.get("/incidents/{incident_id}")
|
||||||
def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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)
|
inc = store.get_incident(conn, incident_id)
|
||||||
if not inc:
|
if not inc:
|
||||||
raise HTTPException(404, "incident not found")
|
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")
|
@router.post("/incidents/{incident_id}/ack")
|
||||||
def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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)
|
inc = store.ack_incident(conn, incident_id, user.username)
|
||||||
if not inc:
|
if not inc:
|
||||||
raise HTTPException(404, "incident not found")
|
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")
|
@router.get("/timeline")
|
||||||
def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)):
|
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 = [
|
ticks = [
|
||||||
dict(r)
|
dict(r)
|
||||||
for r in conn.execute(
|
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")
|
@router.get("/roster")
|
||||||
def agents_roster(user=Depends(auth.get_current_user)):
|
def agents_roster(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
_ops_view(user)
|
_ops_view(user, conn)
|
||||||
return {"agents": roster_public()}
|
return {"agents": roster_public()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/inbox")
|
@router.get("/inbox")
|
||||||
def agents_inbox(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(50, ge=1, le=200)):
|
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)}
|
return {"messages": agent_messages.list_inbox(conn, role=user.role, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/threads")
|
@router.get("/threads")
|
||||||
def agents_threads(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(40, ge=1, le=100)):
|
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)}
|
return {"threads": agent_messages.list_threads(conn, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/threads/{thread_id}/messages")
|
@router.get("/threads/{thread_id}/messages")
|
||||||
def thread_messages(thread_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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():
|
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
|
||||||
raise HTTPException(404, "thread not found")
|
raise HTTPException(404, "thread not found")
|
||||||
return {"thread_id": thread_id, "messages": agent_messages.thread_messages(conn, thread_id)}
|
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")
|
@router.post("/threads/{thread_id}/reply")
|
||||||
def thread_reply(
|
def thread_reply(
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
|
|
@ -160,26 +182,116 @@ def thread_reply(
|
||||||
user=Depends(auth.get_current_user),
|
user=Depends(auth.get_current_user),
|
||||||
conn=Depends(_db),
|
conn=Depends(_db),
|
||||||
):
|
):
|
||||||
_ops_view(user)
|
_ops_view(user, conn)
|
||||||
if not conn.execute("SELECT id FROM agent_threads WHERE id=?", (thread_id,)).fetchone():
|
row = conn.execute("SELECT * FROM agent_threads WHERE id=?", (thread_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
raise HTTPException(404, "thread not found")
|
raise HTTPException(404, "thread not found")
|
||||||
|
target = body.target_agent or row["primary_agent"]
|
||||||
mid = agent_messages.human_reply(
|
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(
|
store.log_event(
|
||||||
conn,
|
conn,
|
||||||
event_type="human.reply",
|
event_type="human.reply",
|
||||||
message=body.body[:120],
|
message=body.body[:120],
|
||||||
agent_id=body.target_agent or "A6",
|
agent_id=target,
|
||||||
payload={"thread_id": thread_id, "user": user.username},
|
payload={"thread_id": thread_id, "user": user.username},
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "message_id": mid}
|
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")
|
@router.post("/messages/{message_id}/ack")
|
||||||
def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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):
|
if not agent_messages.ack_message(conn, message_id, user.username):
|
||||||
raise HTTPException(404, "not found")
|
raise HTTPException(404, "not found")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -188,7 +300,7 @@ def ack_inbox_message(message_id: int, user=Depends(auth.get_current_user), conn
|
||||||
|
|
||||||
@router.get("/scenarios")
|
@router.get("/scenarios")
|
||||||
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
_ops_view(user)
|
_ops_view(user, conn)
|
||||||
runner.sync_registry(conn)
|
runner.sync_registry(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"scenarios": store.list_scenarios(conn)}
|
return {"scenarios": store.list_scenarios(conn)}
|
||||||
|
|
@ -202,13 +314,13 @@ def list_findings(
|
||||||
limit: int = Query(50, ge=1, le=200),
|
limit: int = Query(50, ge=1, le=200),
|
||||||
open_only: bool = True,
|
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)}
|
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/findings/{finding_id}/ack")
|
@router.post("/findings/{finding_id}/ack")
|
||||||
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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():
|
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
|
||||||
raise HTTPException(404, "not found")
|
raise HTTPException(404, "not found")
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
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")
|
@router.get("/action-log")
|
||||||
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)):
|
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(100, ge=1, le=500)):
|
||||||
_ops_view(user)
|
_ops_view(user, conn)
|
||||||
return {"events": store.list_action_log(conn, limit=limit)}
|
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}")
|
@router.post("/runs/{scenario_id}")
|
||||||
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
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")
|
raise HTTPException(403, "insufficient permissions")
|
||||||
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
|
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -239,20 +374,17 @@ def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depe
|
||||||
@router.post("/chat")
|
@router.post("/chat")
|
||||||
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depends(_db)):
|
||||||
"""Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
|
"""Janela de contexto T1 — humano ↔ agente (default Copiloto A6)."""
|
||||||
_ops_view(user)
|
_require_agent_chat(user, conn, body.target_agent)
|
||||||
kb = store.search_kb(conn, body.question)
|
profile = _agent_profile(body.target_agent)
|
||||||
findings_summary = ""
|
kb_snippets, ops_context = _chat_ops_context(conn, body.question, body.target_agent, body.include_findings)
|
||||||
if body.include_findings:
|
|
||||||
open_f = store.list_findings(conn, limit=8, open_only=True)
|
|
||||||
if open_f:
|
|
||||||
findings_summary = "\n".join(
|
|
||||||
f"- [{f['severity']}] {f['title']}: {f.get('suggested_human_action') or ''}" for f in open_f
|
|
||||||
)
|
|
||||||
answer, model = llm_client.chat_context(
|
answer, model = llm_client.chat_context(
|
||||||
question=body.question,
|
question=body.question,
|
||||||
kb_snippets=[k["snippet"] for k in kb],
|
kb_snippets=kb_snippets,
|
||||||
findings_summary=findings_summary,
|
ops_context=ops_context,
|
||||||
user_role=user.role,
|
user_role=user.role,
|
||||||
|
target_agent=body.target_agent,
|
||||||
|
agent_name=profile.name,
|
||||||
|
agent_role=profile.role,
|
||||||
)
|
)
|
||||||
thread_id = agent_messages.create_thread(
|
thread_id = agent_messages.create_thread(
|
||||||
conn,
|
conn,
|
||||||
|
|
@ -277,7 +409,7 @@ def agent_chat(body: ChatRequest, user=Depends(auth.get_current_user), conn=Depe
|
||||||
to_type="human",
|
to_type="human",
|
||||||
to_id=user.username,
|
to_id=user.username,
|
||||||
body=answer,
|
body=answer,
|
||||||
context={"model": model, "kb_hits": len(kb)},
|
context={"model": model, "kb_hits": len(kb_snippets)},
|
||||||
)
|
)
|
||||||
store.log_event(
|
store.log_event(
|
||||||
conn,
|
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},
|
payload={"user": user.username, "model": model, "thread_id": thread_id},
|
||||||
)
|
)
|
||||||
conn.commit()
|
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")
|
@router.post("/internal/tick")
|
||||||
|
|
|
||||||
|
|
@ -298,9 +298,7 @@ def get_overview(conn) -> dict:
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
return {
|
return {
|
||||||
"tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
|
**llm_client.llm_status(),
|
||||||
"ollama": llm_client.ollama_available(),
|
|
||||||
"model": llm_client.AGENTIC_LLM_MODEL,
|
|
||||||
"last_tick_at": last_tick["ts"] if last_tick else None,
|
"last_tick_at": last_tick["ts"] if last_tick else None,
|
||||||
"last_tick_status": "degraded" if payload.get("runs") and any(
|
"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)
|
r.get("findings_count", 0) > 0 for r in payload.get("runs", []) if isinstance(r, dict)
|
||||||
|
|
|
||||||
|
|
@ -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.infra_stack_routes import router as infra_stack_router
|
||||||
from app.vm123.routes import router as vm123_router
|
from app.vm123.routes import router as vm123_router
|
||||||
from app.agents.routes import router as agents_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.agents.store import init_agent_schema
|
||||||
from app.collectors.base import run_audit
|
from app.collectors.base import run_audit
|
||||||
from app.permissions import (
|
from app.permissions import (
|
||||||
|
|
@ -136,6 +137,7 @@ app.include_router(billing_router)
|
||||||
app.include_router(infra_stack_router)
|
app.include_router(infra_stack_router)
|
||||||
app.include_router(vm123_router)
|
app.include_router(vm123_router)
|
||||||
app.include_router(agents_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"
|
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_jobs_schema(conn)
|
||||||
init_purge_auth_schema(conn)
|
init_purge_auth_schema(conn)
|
||||||
init_agent_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 journal_mode=WAL")
|
||||||
conn.execute("PRAGMA busy_timeout=60000")
|
conn.execute("PRAGMA busy_timeout=60000")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
|
||||||
495
projects/ops-desk/api/app/rbac_matrix.py
Normal file
495
projects/ops-desk/api/app/rbac_matrix.py
Normal file
|
|
@ -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",
|
||||||
|
},
|
||||||
|
}
|
||||||
132
projects/ops-desk/api/app/rbac_routes.py
Normal file
132
projects/ops-desk/api/app/rbac_routes.py
Normal file
|
|
@ -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)}
|
||||||
26
projects/ops-desk/api/tests/test_agents_034_kimi.py
Normal file
26
projects/ops-desk/api/tests/test_agents_034_kimi.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -17,6 +17,8 @@ services:
|
||||||
SQLITE_PATH: /data/ops-staging.db
|
SQLITE_PATH: /data/ops-staging.db
|
||||||
REDIS_URL: redis://redis-staging:6379/0
|
REDIS_URL: redis://redis-staging:6379/0
|
||||||
OPS_API_URL: http://api-staging:8080
|
OPS_API_URL: http://api-staging:8080
|
||||||
|
ACCESS_MATRIX_UI: "1"
|
||||||
|
ACCESS_MATRIX_EDIT: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/lib/ligbox-ops-platform-staging:/data
|
- /var/lib/ligbox-ops-platform-staging:/data
|
||||||
- ./specs:/opt/ligbox-ops-platform/specs:ro
|
- ./specs:/opt/ligbox-ops-platform/specs:ro
|
||||||
|
|
|
||||||
955
projects/ops-desk/frontend/assets/access-matrix.css
Normal file
955
projects/ops-desk/frontend/assets/access-matrix.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
651
projects/ops-desk/frontend/assets/access-matrix.js
Normal file
651
projects/ops-desk/frontend/assets/access-matrix.js
Normal file
|
|
@ -0,0 +1,651 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').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 `<span class="am-badge am-badge--${meta.access}${cls}" title="${esc(meta.label)}">${short}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `
|
||||||
|
<div class="am-role-group">
|
||||||
|
<h4>${esc(g.label)}</h4>
|
||||||
|
${g.roles.map((r) => `
|
||||||
|
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
|
||||||
|
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScopeBar() {
|
||||||
|
return `<div class="am-scope-bar">
|
||||||
|
${(state.data.scope_layers || []).map((l) => `
|
||||||
|
<div class="am-scope-item">
|
||||||
|
<span class="am-scope-label">${esc(l.label)}</span>
|
||||||
|
<span class="am-scope-host">${esc(l.host)}</span>
|
||||||
|
<span class="am-scope-desc">${esc(l.desc)}</span>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<th class="${hl.trim()}" title="${esc(c)}">${esc(label.split(' ')[0])}</th>`;
|
||||||
|
}).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 `<td class="${tdHl.trim()}">${badgeHtml(lv)}</td>`;
|
||||||
|
}).join('');
|
||||||
|
const sub = row.product
|
||||||
|
? `<span class="am-row-sub">${esc(row.product)}</span>`
|
||||||
|
: `<code class="am-row-code">${esc(row[idKey] || '')}</code>`;
|
||||||
|
return `<tr>
|
||||||
|
<td class="am-sticky">
|
||||||
|
<span class="am-row-title">${esc(row.label)}</span>
|
||||||
|
${sub}
|
||||||
|
</td>${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="am-table-wrap">
|
||||||
|
<table class="am-matrix-table">
|
||||||
|
<thead><tr><th class="am-sticky">${esc(rowLabel)}</th>${head}</tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeskMatrix() {
|
||||||
|
return `
|
||||||
|
<p class="am-section-lead">Grelha completa — módulos internos do Desk (VM122). Use <strong>Visão da função</strong> para resumo ou <strong>Software & Infra</strong> para VM112/123.</p>
|
||||||
|
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBindingCards(bindings, title) {
|
||||||
|
if (!bindings.length) {
|
||||||
|
return `<p class="am-empty">${title ? esc(title) + ' — ' : ''}sem registos para esta função.</p>`;
|
||||||
|
}
|
||||||
|
const byService = {};
|
||||||
|
bindings.forEach((b) => {
|
||||||
|
if (!byService[b.service]) byService[b.service] = [];
|
||||||
|
byService[b.service].push(b);
|
||||||
|
});
|
||||||
|
return `
|
||||||
|
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||||||
|
<div class="am-api-grid">
|
||||||
|
${Object.entries(byService).flatMap(([svc, items]) =>
|
||||||
|
items.map((b) => `
|
||||||
|
<article class="am-api-card am-svc-${esc(b.service)}">
|
||||||
|
<header class="am-api-card-head">
|
||||||
|
<span class="am-api-service">${esc(serviceLabel(svc))}</span>
|
||||||
|
${badgeHtml(b.access || 'full', { compact: true })}
|
||||||
|
</header>
|
||||||
|
<div class="am-api-type">${esc(b.type || 'binding')}</div>
|
||||||
|
<code class="am-api-value">${esc(b.value)}</code>
|
||||||
|
<footer class="am-api-foot">${esc(svc)}</footer>
|
||||||
|
</article>`)
|
||||||
|
).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSoftwareCards(rows, title) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return `<p class="am-empty">${esc(title || 'Software')} — sem acesso directo.</p>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||||||
|
<div class="am-sw-grid">
|
||||||
|
${rows.map((r) => {
|
||||||
|
const meta = levelMeta(r.level);
|
||||||
|
return `<article class="am-sw-card">
|
||||||
|
<header class="am-sw-card-head">
|
||||||
|
<div>
|
||||||
|
<strong>${esc(r.label)}</strong>
|
||||||
|
<span class="am-sw-product">${esc(r.product)}</span>
|
||||||
|
</div>
|
||||||
|
${badgeHtml(r.level, { compact: true })}
|
||||||
|
</header>
|
||||||
|
<p class="am-sw-group">${esc(r.group)}</p>
|
||||||
|
${r.host && r.host !== '—' ? `<code class="am-sw-host">${esc(r.host)}</code>` : ''}
|
||||||
|
</article>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeskChips() {
|
||||||
|
const rows = roleDeskRows();
|
||||||
|
if (!rows.length) {
|
||||||
|
return '<p class="am-empty">Sem módulos Desk activos para esta função.</p>';
|
||||||
|
}
|
||||||
|
return `<div class="am-desk-chips">
|
||||||
|
${rows.map((r) => `
|
||||||
|
<span class="am-desk-chip" title="${esc(r.id)}">
|
||||||
|
${esc(r.label)}
|
||||||
|
${badgeHtml(r.level, { compact: true })}
|
||||||
|
</span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleStats(role) {
|
||||||
|
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||||||
|
const bindings = role?.bindings || [];
|
||||||
|
const desk = roleDeskRows();
|
||||||
|
return `<div class="am-stats">
|
||||||
|
<div class="am-stat"><span class="am-stat-n">${desk.length}</span><span class="am-stat-l">Módulos Desk</span></div>
|
||||||
|
<div class="am-stat"><span class="am-stat-n">${sw.length}</span><span class="am-stat-l">Software</span></div>
|
||||||
|
<div class="am-stat"><span class="am-stat-n">${bindings.length}</span><span class="am-stat-l">APIs / Grupos</span></div>
|
||||||
|
<div class="am-stat"><span class="am-stat-n">${(state.data.role_agents_summaries?.[state.selectedRole] || []).length}</span><span class="am-stat-l">Agentes</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}
|
||||||
|
<section class="am-block">
|
||||||
|
<h4 class="am-block-title">Desk VM122 — módulos activos</h4>
|
||||||
|
${renderDeskChips()}
|
||||||
|
</section>
|
||||||
|
<section class="am-block">
|
||||||
|
${renderSoftwareCards(sw, 'Software & infra em escopo')}
|
||||||
|
</section>
|
||||||
|
<section class="am-block">
|
||||||
|
${renderBindingCards(bindings, 'APIs, grupos Odoo e permissões')}
|
||||||
|
</section>
|
||||||
|
<section class="am-block">
|
||||||
|
<h4 class="am-block-title">Agentics — capacidades da função</h4>
|
||||||
|
${renderRoleAgentCaps()}
|
||||||
|
${renderRoleAgentsSummary('')}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="am-filter-bar">
|
||||||
|
${filters.map((f) => `
|
||||||
|
<button type="button" class="am-filter${state.softwareFilter === f.id ? ' active' : ''}"
|
||||||
|
data-am-sw-filter="${esc(f.id)}">${esc(f.label)}</button>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const sections = groups.map((grp) => {
|
||||||
|
const meta = [grp.host, grp.url].filter(Boolean).join(' · ');
|
||||||
|
return `
|
||||||
|
<section class="am-sw-section">
|
||||||
|
<header class="am-sw-section-head">
|
||||||
|
<div>
|
||||||
|
<h4>${esc(grp.label)}</h4>
|
||||||
|
${meta ? `<span class="am-sw-meta">${esc(meta)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
${renderMatrixTable(grp.items || [], 'Recurso', 'id')}
|
||||||
|
</section>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||||||
|
return `
|
||||||
|
<p class="am-section-lead">Matriz de software fora do Desk — VM112 (onboard/mail), VM123 (finance/hosting) e consolas de infra.</p>
|
||||||
|
${renderSoftwareCards(sw, 'Resumo da função seleccionada')}
|
||||||
|
${filterBar}${sections}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBindings() {
|
||||||
|
const role = state.data.catalog.roles[state.selectedRole];
|
||||||
|
if (!role) return '<p class="am-empty">Função não encontrada</p>';
|
||||||
|
return `
|
||||||
|
<p class="am-section-lead">Bindings estilo Odoo — grupos, roles e permissões provisionados por função.</p>
|
||||||
|
${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 `<span class="am-rel am-rel--${esc(rel)}">${esc(labels[rel] || rel)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleChips(roleIds) {
|
||||||
|
return (roleIds || []).map((r) =>
|
||||||
|
`<span class="am-role-chip${r === state.selectedRole ? ' am-role-chip--sel' : ''}" title="${esc(r)}">${esc(roleLabel(r))}</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentGovernanceLegend() {
|
||||||
|
const labels = state.data.agent_relation_labels || {};
|
||||||
|
return `<div class="am-agent-legend">
|
||||||
|
${Object.entries(labels).map(([k, v]) =>
|
||||||
|
`<span class="am-agent-legend-item">${renderRelationChip(k)} ${esc(v)}</span>`
|
||||||
|
).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 '<p class="am-empty">Esta função não acede ao módulo Agentics.</p>';
|
||||||
|
}
|
||||||
|
return `<div class="am-cap-chips">
|
||||||
|
${caps.map((c) => `<span class="am-cap-chip">${esc(c.label)}</span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="am-cap-toggles">
|
||||||
|
${capsDef.map(([id, label]) => {
|
||||||
|
const meta = capMeta(id, roleId);
|
||||||
|
return `<button type="button"
|
||||||
|
class="am-cap-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||||||
|
${meta.locked ? 'disabled' : ''}
|
||||||
|
data-am-cap="${esc(id)}|${esc(roleId)}"
|
||||||
|
title="${esc(label)}">${esc(label)}</button>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleAgentsSummary(title) {
|
||||||
|
const rows = state.data.role_agents_summaries?.[state.selectedRole] || [];
|
||||||
|
if (!rows.length) {
|
||||||
|
return `<p class="am-empty">${esc(title || 'Agentes')} — sem interacção directa.</p>`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||||||
|
<div class="am-agent-mini-grid">
|
||||||
|
${rows.map((a) => `
|
||||||
|
<article class="am-agent-mini${a.is_approver ? ' am-agent-mini--approve' : ''}">
|
||||||
|
<header>
|
||||||
|
<span class="am-agent-id">${esc(a.id)}</span>
|
||||||
|
<strong>${esc(a.name)}</strong>
|
||||||
|
</header>
|
||||||
|
<p class="am-agent-role">${esc(a.role)}</p>
|
||||||
|
<div class="am-rel-row">${(a.relations || []).map(renderRelationChip).join('')}</div>
|
||||||
|
</article>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgentCard(a) {
|
||||||
|
const rels = a.role_relations?.[state.selectedRole] || [];
|
||||||
|
const hit = rels.length > 0;
|
||||||
|
return `
|
||||||
|
<article class="am-agent-card${hit ? ' am-agent-card--hit' : ''}">
|
||||||
|
<header class="am-agent-top">
|
||||||
|
<span class="am-agent-id">${esc(a.id)}</span>
|
||||||
|
<div>
|
||||||
|
<strong>${esc(a.name)}</strong>
|
||||||
|
<span class="am-agent-codename">${esc(a.codename)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p class="am-agent-role">${esc(a.role)}</p>
|
||||||
|
${hit ? `<div class="am-rel-row am-rel-row--you">${rels.map(renderRelationChip).join('')} <span class="am-you">esta função</span></div>` : ''}
|
||||||
|
<div class="am-agent-section">
|
||||||
|
<span class="am-agent-lbl">Aprova</span>
|
||||||
|
${renderRoleChips(a.approvers)}
|
||||||
|
</div>
|
||||||
|
<div class="am-agent-section">
|
||||||
|
<span class="am-agent-lbl">Operadores</span>
|
||||||
|
${renderRoleChips(a.operators)}
|
||||||
|
</div>
|
||||||
|
${(a.reads || []).length ? `
|
||||||
|
<div class="am-agent-section">
|
||||||
|
<span class="am-agent-lbl">Lê</span>
|
||||||
|
<span class="am-agent-meta">${esc(a.reads.slice(0, 3).join(' · '))}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
<footer class="am-agent-approval">
|
||||||
|
<span>Regra Spec 027</span>
|
||||||
|
<code>${esc(a.approval_text)}</code>
|
||||||
|
</footer>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRelationToggle(agentId, roleId, relation) {
|
||||||
|
const meta = bindingMeta(agentId, roleId, relation);
|
||||||
|
const short = { ui: 'UI', focus: 'OP', approve: 'AP' }[relation] || relation;
|
||||||
|
return `<button type="button"
|
||||||
|
class="am-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||||||
|
${meta.locked ? 'disabled' : ''}
|
||||||
|
data-am-toggle="${esc(agentId)}|${esc(roleId)}|${relation}"
|
||||||
|
title="${esc(relation)}${meta.locked ? ' (obrigatório)' : ''}">${short}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('')
|
||||||
|
: '<span class="am-rel am-rel--none">—</span>';
|
||||||
|
return `<td class="${tdHl.trim()}">${inner}</td>`;
|
||||||
|
}
|
||||||
|
return `<td class="am-cell-toggles${tdHl.trim()}">
|
||||||
|
${renderRelationToggle(agentId, role, 'ui')}
|
||||||
|
${renderRelationToggle(agentId, role, 'focus')}
|
||||||
|
${renderRelationToggle(agentId, role, 'approve')}
|
||||||
|
</td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `<th class="${hl.trim()}" title="${esc(c)}">${esc(roleLabel(c).split(' ')[0])}</th>`;
|
||||||
|
}).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 `<tr>
|
||||||
|
<td class="am-sticky">
|
||||||
|
<span class="am-row-title">${esc(a.id)} · ${esc(a.name)}</span>
|
||||||
|
<span class="am-row-sub">${esc(a.role)}</span>
|
||||||
|
</td>${cells}</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<h4 class="am-block-title">Matriz agente × função</h4>
|
||||||
|
${renderAgentGovernanceLegend()}
|
||||||
|
<div class="am-table-wrap">
|
||||||
|
<table class="am-matrix-table">
|
||||||
|
<thead><tr><th class="am-sticky">Agente</th>${head}</tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgents() {
|
||||||
|
const caps = state.data.role_agentic_caps?.[state.selectedRole] || [];
|
||||||
|
return `
|
||||||
|
<p class="am-section-lead">
|
||||||
|
Agentes A0–A7 usam conta <code>agent_system</code>.
|
||||||
|
${state.data.editable
|
||||||
|
? 'Clique <strong>UI / OP / AP</strong> para ligar ou desligar atribuições (audit activo).'
|
||||||
|
: 'Três relações: UI · Operador · Aprova.'}
|
||||||
|
</p>
|
||||||
|
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||||||
|
${(state.data.editable || caps.length) ? `<section class="am-block"><h4 class="am-block-title">Capacidades — ${esc(roleLabel(state.selectedRole))}</h4>${renderRoleAgentCaps()}</section>` : ''}
|
||||||
|
${renderAgentsMatrix()}
|
||||||
|
<section class="am-block">
|
||||||
|
<h4 class="am-block-title">Detalhe por agente</h4>
|
||||||
|
<div class="am-agents-grid">
|
||||||
|
${(state.data.agents || []).map(renderAgentCard).join('')}
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend() {
|
||||||
|
const legend = state.data.legend || {};
|
||||||
|
return Object.entries(legend).map(([key, l]) =>
|
||||||
|
`<span class="am-legend-item">${badgeHtml(key, { compact: true })} ${esc(l.label)}</span>`
|
||||||
|
).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 `
|
||||||
|
<div class="am-panel">
|
||||||
|
<header class="am-panel-head">
|
||||||
|
<div class="am-panel-title">
|
||||||
|
<span class="am-panel-kicker">Spec 027 · RBAC</span>
|
||||||
|
<h3>${esc(role?.label || state.selectedRole)}</h3>
|
||||||
|
<p class="am-panel-desc">${esc(role?.description || '')}</p>
|
||||||
|
${activeTabHint() ? `<p class="am-tab-hint">${esc(activeTabHint())}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="am-legend">${renderLegend()}</div>
|
||||||
|
</header>
|
||||||
|
<div class="am-panel-body">${body}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabs() {
|
||||||
|
return (state.data.tabs || []).map((t) =>
|
||||||
|
`<button type="button" class="am-tab${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`
|
||||||
|
).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 = `
|
||||||
|
<div class="am-wrap">
|
||||||
|
<div class="am-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="am-page-title">Matriz de Acessos</h2>
|
||||||
|
<p class="am-page-sub">Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa</p>
|
||||||
|
</div>
|
||||||
|
<span class="am-preview-tag">${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''}</span>
|
||||||
|
</div>
|
||||||
|
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||||||
|
<nav class="am-subnav">${renderTabs()}</nav>
|
||||||
|
<div class="am-layout">
|
||||||
|
<aside class="am-role-list">${renderRoleSidebar()}</aside>
|
||||||
|
${renderMainPanel()}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
bindEvents(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderAccessMatrix() {
|
||||||
|
const root = document.getElementById('access-matrix-content');
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = '<p class="loading">Carregando matriz de acessos…</p>';
|
||||||
|
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 = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.renderAccessMatrix = renderAccessMatrix;
|
||||||
|
window.DeskAccessMatrix = { renderAccessMatrix };
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -42,8 +42,10 @@ function sessionHashHtml(sessionId, { full = true } = {}) {
|
||||||
return `<code class="session-hash" title="Sessão onboarding VM112">${esc(shown)}</code>`;
|
return `<code class="session-hash" title="Sessão onboarding VM112">${esc(shown)}</code>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VIEW = 'agentic-ops';
|
||||||
|
|
||||||
let state = {
|
let state = {
|
||||||
view: 'dashboard',
|
view: DEFAULT_VIEW,
|
||||||
ticketFilter: 'all',
|
ticketFilter: 'all',
|
||||||
sourceFilter: 'all',
|
sourceFilter: 'all',
|
||||||
eventSourceFilter: 'all',
|
eventSourceFilter: 'all',
|
||||||
|
|
@ -62,6 +64,7 @@ let state = {
|
||||||
adminUsers: [],
|
adminUsers: [],
|
||||||
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
|
adminFilter: { q: '', role: 'all', status: 'all', mfa: 'all' },
|
||||||
adminSelected: null,
|
adminSelected: null,
|
||||||
|
features: { access_matrix_ui: false },
|
||||||
socWindow: '24h',
|
socWindow: '24h',
|
||||||
socLastEventId: null,
|
socLastEventId: null,
|
||||||
};
|
};
|
||||||
|
|
@ -79,6 +82,7 @@ const views = {
|
||||||
'agentic-ops': document.getElementById('view-agentic-ops'),
|
'agentic-ops': document.getElementById('view-agentic-ops'),
|
||||||
messages: document.getElementById('view-messages'),
|
messages: document.getElementById('view-messages'),
|
||||||
admin: document.getElementById('view-admin'),
|
admin: document.getElementById('view-admin'),
|
||||||
|
'access-matrix': document.getElementById('view-access-matrix'),
|
||||||
account: document.getElementById('view-account'),
|
account: document.getElementById('view-account'),
|
||||||
leads: document.getElementById('view-leads'),
|
leads: document.getElementById('view-leads'),
|
||||||
modules: document.getElementById('view-modules'),
|
modules: document.getElementById('view-modules'),
|
||||||
|
|
@ -194,9 +198,34 @@ function applyRoleNav() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDeskFeatures() {
|
||||||
|
try {
|
||||||
|
const data = await api('/v1/config/features');
|
||||||
|
state.features = { ...state.features, ...data };
|
||||||
|
} catch {
|
||||||
|
state.features = { access_matrix_ui: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccessMatrixNav() {
|
||||||
|
const nav = document.getElementById('nav-access-matrix');
|
||||||
|
if (!nav) return;
|
||||||
|
const user = getUser();
|
||||||
|
const show = !!state.features?.access_matrix_ui && user?.role === 'super_admin';
|
||||||
|
if (show) nav.removeAttribute('hidden');
|
||||||
|
else nav.setAttribute('hidden', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultView() {
|
||||||
|
if (window.DeskModules?.loaded && DeskModules.isViewEnabled(DEFAULT_VIEW)) return DEFAULT_VIEW;
|
||||||
|
return 'dashboard';
|
||||||
|
}
|
||||||
|
|
||||||
function setView(name) {
|
function setView(name) {
|
||||||
if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
|
if (name === 'access-matrix' && !state.features?.access_matrix_ui) {
|
||||||
name = 'dashboard';
|
name = resolveDefaultView();
|
||||||
|
} else if (window.DeskModules?.loaded && !DeskModules.isViewEnabled(name)) {
|
||||||
|
name = resolveDefaultView();
|
||||||
}
|
}
|
||||||
if (state.view === 'account' && name !== 'account') {
|
if (state.view === 'account' && name !== 'account') {
|
||||||
state.accountLoaded = false;
|
state.accountLoaded = false;
|
||||||
|
|
@ -214,6 +243,7 @@ function setView(name) {
|
||||||
'agentic-ops': 'Agentic Ops',
|
'agentic-ops': 'Agentic Ops',
|
||||||
messages: 'Mensagens — pedidos de cadastro',
|
messages: 'Mensagens — pedidos de cadastro',
|
||||||
admin: 'Administradores',
|
admin: 'Administradores',
|
||||||
|
'access-matrix': 'Matriz de Acessos',
|
||||||
account: 'Minha conta',
|
account: 'Minha conta',
|
||||||
leads: 'Leads abandonados',
|
leads: 'Leads abandonados',
|
||||||
modules: 'Módulos',
|
modules: 'Módulos',
|
||||||
|
|
@ -230,6 +260,7 @@ function setView(name) {
|
||||||
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
|
'agentic-ops': 'Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)',
|
||||||
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
messages: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
admin: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
|
'access-matrix': 'Preview read-only — Spec 027 · funções × VM112/122/123 (estilo Odoo groups)',
|
||||||
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
account: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
leads: 'Operações Ligbox — onboarding, tickets e monitoramento',
|
||||||
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
|
modules: 'Activar ou desativar funcionalidades do Desk sem afectar o núcleo',
|
||||||
|
|
@ -2996,7 +3027,7 @@ async function renderModules() {
|
||||||
await DeskModules.load();
|
await DeskModules.load();
|
||||||
applyRoleNav();
|
applyRoleNav();
|
||||||
DeskModules.applyVisibility();
|
DeskModules.applyVisibility();
|
||||||
if (!DeskModules.isViewEnabled(state.view)) setView('dashboard');
|
if (!DeskModules.isViewEnabled(state.view)) setView(resolveDefaultView());
|
||||||
else refresh();
|
else refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
input.checked = !input.checked;
|
input.checked = !input.checked;
|
||||||
|
|
@ -4227,6 +4258,7 @@ async function refresh(options = {}) {
|
||||||
if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps();
|
if (state.view === 'agentic-ops' && window.renderAgenticOps) await window.renderAgenticOps();
|
||||||
if (state.view === 'messages') await renderMessages();
|
if (state.view === 'messages') await renderMessages();
|
||||||
if (state.view === 'admin') await renderAdmin();
|
if (state.view === 'admin') await renderAdmin();
|
||||||
|
if (state.view === 'access-matrix' && window.renderAccessMatrix) await window.renderAccessMatrix();
|
||||||
if (state.view === 'modules') await renderModules();
|
if (state.view === 'modules') await renderModules();
|
||||||
if (state.view === 'account') await renderAccount();
|
if (state.view === 'account') await renderAccount();
|
||||||
}
|
}
|
||||||
|
|
@ -4285,14 +4317,16 @@ document.getElementById('btn-refresh')?.addEventListener('click', () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setupSidebarUser();
|
setupSidebarUser();
|
||||||
|
await loadDeskFeatures();
|
||||||
await DeskModules.load();
|
await DeskModules.load();
|
||||||
applyRoleNav();
|
applyRoleNav();
|
||||||
DeskModules.applyVisibility();
|
DeskModules.applyVisibility();
|
||||||
|
applyAccessMatrixNav();
|
||||||
bindOverviewModal();
|
bindOverviewModal();
|
||||||
bindInfraProcessModal();
|
bindInfraProcessModal();
|
||||||
bindTeamDrawerClose();
|
bindTeamDrawerClose();
|
||||||
bindSocTestModal();
|
bindSocTestModal();
|
||||||
setView('dashboard');
|
setView(DEFAULT_VIEW);
|
||||||
|
|
||||||
ensureValidSession().then((valid) => {
|
ensureValidSession().then((valid) => {
|
||||||
if (!valid) window.location.replace('/login.html');
|
if (!valid) window.location.replace('/login.html');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
<title>Ligbox Ops — Support Desk</title>
|
<title>Ligbox Ops — Support Desk</title>
|
||||||
<link rel="stylesheet" href="/assets/styles.css?v=20260619tickets2"/>
|
<link rel="stylesheet" href="/assets/styles.css?v=20260619tickets2"/>
|
||||||
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
|
<link rel="stylesheet" href="/assets/tickets-workspace.css?v=20260619tickets2"/>
|
||||||
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v2"/>
|
<link rel="stylesheet" href="/assets/agentic-ops.css?v=20260620v10"/>
|
||||||
|
<link rel="stylesheet" href="/assets/access-matrix.css?v=20260620am7"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
<svg width="0" height="0" style="position:absolute;visibility:hidden" aria-hidden="true" focusable="false">
|
||||||
|
|
@ -186,7 +187,11 @@
|
||||||
<p>Support Desk · Ibytera</p>
|
<p>Support Desk · Ibytera</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<button type="button" data-view="dashboard" data-module="core" class="active nav-item nav-item-dashboard">
|
<button type="button" data-view="agentic-ops" data-module="agentic-ops" id="nav-agentic-ops" class="active nav-item nav-item-agentic">
|
||||||
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
|
||||||
|
<span class="nav-label">Agentic Ops</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-view="dashboard" data-module="core" class="nav-item nav-item-dashboard">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-dashboard"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-dashboard"/></svg></span>
|
||||||
<span class="nav-label">Dashboard</span>
|
<span class="nav-label">Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -226,10 +231,6 @@
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra2"/></svg></span>
|
||||||
<span class="nav-label">Infra 2 <span class="nav-badge-new">SOC</span></span>
|
<span class="nav-label">Infra 2 <span class="nav-badge-new">SOC</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" data-view="agentic-ops" data-module="agentic-ops" id="nav-agentic-ops" class="nav-item nav-item-agentic">
|
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-infra"/></svg></span>
|
|
||||||
<span class="nav-label">Agentic Ops</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-view="account" data-module="core" id="nav-account" class="nav-item nav-item-account">
|
<button type="button" data-view="account" data-module="core" id="nav-account" class="nav-item nav-item-account">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-account"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-account"/></svg></span>
|
||||||
<span class="nav-label">Minha conta</span>
|
<span class="nav-label">Minha conta</span>
|
||||||
|
|
@ -242,6 +243,10 @@
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
|
||||||
<span class="nav-label">Administradores</span>
|
<span class="nav-label">Administradores</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" data-view="access-matrix" id="nav-access-matrix" hidden class="nav-item nav-item-admin">
|
||||||
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
|
||||||
|
<span class="nav-label">Matriz <span class="nav-badge-new">β</span></span>
|
||||||
|
</button>
|
||||||
<button type="button" data-view="modules" data-module="modules-admin" id="nav-modules" hidden class="nav-item nav-item-modules">
|
<button type="button" data-view="modules" data-module="modules-admin" id="nav-modules" hidden class="nav-item nav-item-modules">
|
||||||
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
|
<span class="nav-icon-wrap" aria-hidden="true"><svg class="nav-icon-svg"><use href="#icon-admin"/></svg></span>
|
||||||
<span class="nav-label">Módulos</span>
|
<span class="nav-label">Módulos</span>
|
||||||
|
|
@ -254,8 +259,8 @@
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="page-title">Dashboard</h2>
|
<h2 id="page-title">Agentic Ops</h2>
|
||||||
<p id="page-subtitle">Operações Ligbox — onboarding, tickets e monitoramento</p>
|
<p id="page-subtitle">Vigilância 24/7, findings, advisor IA e copiloto ops (Spec 029)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-toolbar" id="page-toolbar">
|
<div class="page-toolbar" id="page-toolbar">
|
||||||
<span id="header-user" class="header-user" hidden></span>
|
<span id="header-user" class="header-user" hidden></span>
|
||||||
|
|
@ -265,7 +270,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="view-dashboard" class="view active">
|
<section id="view-dashboard" class="view">
|
||||||
<div id="dashboard-content"><p class="loading">Carregando…</p></div>
|
<div id="dashboard-content"><p class="loading">Carregando…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -331,7 +336,7 @@
|
||||||
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
|
<div id="infra2-content"><p class="loading">Carregando SOC…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-agentic-ops" class="view">
|
<section id="view-agentic-ops" class="view active">
|
||||||
<div id="agentic-ops-content"><p class="loading">Carregando Agentic Ops…</p></div>
|
<div id="agentic-ops-content"><p class="loading">Carregando Agentic Ops…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -347,6 +352,10 @@
|
||||||
<div id="admin-content"><p class="loading">Carregando…</p></div>
|
<div id="admin-content"><p class="loading">Carregando…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="view-access-matrix" class="view">
|
||||||
|
<div id="access-matrix-content"><p class="loading">Carregando matriz…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="view-modules" class="view">
|
<section id="view-modules" class="view">
|
||||||
<div id="modules-content"><p class="loading">Carregando…</p></div>
|
<div id="modules-content"><p class="loading">Carregando…</p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -444,7 +453,8 @@
|
||||||
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-workspace.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
<script src="/assets/tickets-detail-panel.js?v=20260619tickets2"></script>
|
||||||
<script src="/assets/servicos.js?v=20260620agentic"></script>
|
<script src="/assets/servicos.js?v=20260620agentic"></script>
|
||||||
<script src="/assets/agentic-ops.js?v=20260620v2"></script>
|
<script src="/assets/agentic-ops.js?v=20260620v10"></script>
|
||||||
<script src="/assets/app.js?v=20260620v2"></script>
|
<script src="/assets/access-matrix.js?v=20260620am7"></script>
|
||||||
|
<script src="/assets/app.js?v=20260620v4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -359,8 +359,9 @@ Ver [`contracts/vm123-product-roles.md`](contracts/vm123-product-roles.md).
|
||||||
|
|
||||||
## 11. Documentos relacionados
|
## 11. Documentos relacionados
|
||||||
|
|
||||||
| Spec | Relação |
|
| Documento | Relação |
|
||||||
|------|---------|
|
|-----------|---------|
|
||||||
|
| **ui-access-matrix.md** | Página Desk preview (feature flag, wireframe, referências UI) |
|
||||||
| **003** | RBAC base (4 roles) — **pai** |
|
| **003** | RBAC base (4 roles) — **pai** |
|
||||||
| **004** | Cadastro e atribuição de perfil |
|
| **004** | Cadastro e atribuição de perfil |
|
||||||
| **015** | Registry módulos Desk |
|
| **015** | Registry módulos Desk |
|
||||||
|
|
|
||||||
160
specs/027-desk-rbac-function-matrix/ui-access-matrix.md
Normal file
160
specs/027-desk-rbac-function-matrix/ui-access-matrix.md
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Spec 027-UI — Matriz de Acessos (Página Desk)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-20
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Status:** 🔄 Preview (read-only, feature flag)
|
||||||
|
**Sistema:** Desk VM122 · view `access-matrix`
|
||||||
|
**Relacionado:** Spec **027** (matriz) · Spec **004** (cadastro) · `platform_role_catalog.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Página visual **estilo Odoo Groups + Permission Matrix** para consultar a matriz de funções Ligbox (VM112 / VM122 / VM123 / externas / agentes), **sem alterar** produção até Roger aprovar as telas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Princípios de entrega
|
||||||
|
|
||||||
|
| Regra | Implementação |
|
||||||
|
|-------|---------------|
|
||||||
|
| Zero impacto produção | `ACCESS_MATRIX_UI=0` (default) — menu oculto |
|
||||||
|
| Preview staging | `ACCESS_MATRIX_UI=1` no compose staging (`:8192`) |
|
||||||
|
| Só leitura v1 | Sem PATCH de roles/módulos na matriz |
|
||||||
|
| Fonte de verdade | `platform_role_catalog.py` + tabelas Spec 027 §3 |
|
||||||
|
| Quem vê | `super_admin` apenas (preview) |
|
||||||
|
| Não substituir | Página **Equipe Ligbox** (`admin-users`) mantida |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature flag
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env / docker-compose staging
|
||||||
|
ACCESS_MATRIX_UI=1 # staging preview
|
||||||
|
ACCESS_MATRIX_UI=0 # produção (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
API:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/config/features
|
||||||
|
→ { "access_matrix_ui": true, "preview_mode": true }
|
||||||
|
|
||||||
|
GET /api/v1/rbac/matrix
|
||||||
|
→ catálogo completo (roles, módulos Desk, bindings, agentes, legenda)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referências UI (modelos a seguir)
|
||||||
|
|
||||||
|
### GitHub — RBAC admin / permission matrix
|
||||||
|
|
||||||
|
| Repo | O que reutilizar | URL |
|
||||||
|
|------|------------------|-----|
|
||||||
|
| **rbac-admin-platform** | Dashboard admin, tabela role×ação, audit, dark mode | https://github.com/bankotij/rbac-admin-platform |
|
||||||
|
| **RBAC-UI** (jagadish-pattanaik) | `PermissionMatrix.js`, sidebar roles, grouping | https://github.com/jagadish-pattanaik/RBAC-UI |
|
||||||
|
| **rbac-ui** (balamuruganpm) | User/role tables, collapsible sidebar | https://github.com/balamuruganpm/rbac-ui |
|
||||||
|
|
||||||
|
### shadcn/ui blocks (padrão visual da grelha)
|
||||||
|
|
||||||
|
| Block | O que reutilizar | URL |
|
||||||
|
|-------|------------------|-----|
|
||||||
|
| **Tables Permission Matrix** | Linhas agrupadas por módulo, colunas = roles, badges de nível | https://www.shadcn.io/blocks/tables-permission-matrix |
|
||||||
|
| **Dashboard Permission Matrix** | Grelha recurso×role, cores none/read/write/admin | https://www.shadcn.io/blocks/dashboard-permission-matrix |
|
||||||
|
|
||||||
|
### Odoo (modelo conceptual — não fork)
|
||||||
|
|
||||||
|
| Conceito Odoo | Equivalente Ligbox |
|
||||||
|
|---------------|-------------------|
|
||||||
|
| `res.users` + `groups_id` | `desk_users.role` + provisionamento VM123 |
|
||||||
|
| `res.groups` | `PLATFORM_ROLE_CATALOG[*].bindings` |
|
||||||
|
| `ir.model.access` | `permissions.py` + route guards |
|
||||||
|
| `ir.rule` (record rules) | mascaramento NOC, ticket assignee |
|
||||||
|
| Settings → Users → Groups UI | Página Matriz de Acessos |
|
||||||
|
|
||||||
|
Docs: https://www.odoo.com/documentation/16.0/developer/reference/backend/security.html
|
||||||
|
|
||||||
|
### Desk Ligbox (consistência visual)
|
||||||
|
|
||||||
|
- Paleta existente: `styles.css` (`--accent`, `--ok`, `--warn`, `--sidebar-bg`)
|
||||||
|
- Padrão módulo isolado: `agentic-ops.js` + CSS scoped (`#access-matrix-content`)
|
||||||
|
- Tabs admin: Equipe Ligbox | Matriz (quando flag ON)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wireframe (Preview v1)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ PREVIEW — read-only · Spec 027 ─────────────────────────────────────┐
|
||||||
|
│ [ Equipe Ligbox ] [ Matriz de Acessos ● ] │
|
||||||
|
├──────────────┬───────────────────────────────────────────────────────┤
|
||||||
|
│ Funções │ [ VM122 Desk ] [ VM112 ] [ VM123 ] [ Externas ] [A0-A7]│
|
||||||
|
│ ───────── │ │
|
||||||
|
│ ▶ Ops │ Grelha módulo × função (✅ 🔒 ❌ ⚙️ 🔗) │
|
||||||
|
│ super_admin│ ou painel bindings por serviço (Odoo groups) │
|
||||||
|
│ ops_lead │ │
|
||||||
|
│ ▶ Comercial │ Ao seleccionar função → detalhe bindings: │
|
||||||
|
│ sales_admin│ vm123_odoo: sales_team.group_sale_manager │
|
||||||
|
│ … │ vm123_foss: ligbox-sales-admin │
|
||||||
|
└──────────────┴───────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases
|
||||||
|
|
||||||
|
### Fase UI-A — Preview (esta entrega) ✅
|
||||||
|
|
||||||
|
- [x] Spec UI (este documento)
|
||||||
|
- [x] `GET /api/v1/rbac/matrix` read-only
|
||||||
|
- [x] `GET /api/v1/config/features`
|
||||||
|
- [x] `access-matrix.js` + CSS scoped
|
||||||
|
- [x] Flag staging ON, produção OFF
|
||||||
|
- [x] Tab **Software em escopo** (VM112 / VM123 / consolas externas — Spec 027 §2, §4, §5)
|
||||||
|
- [x] Cartão por função (resumo SW ≠ none)
|
||||||
|
- [ ] Roger aprova layout
|
||||||
|
|
||||||
|
### Fase UI-A2 — Melhorias sugeridas (backlog)
|
||||||
|
|
||||||
|
| Melhoria | Descrição | Prioridade |
|
||||||
|
|----------|-----------|------------|
|
||||||
|
| **Visão unificada** | Uma grelha Desk + SW (coluna «camada»: Desk / VM112 / VM123 / Externa) | Alta |
|
||||||
|
| **Deep-links activos** | Clicar 🔗 abre URL do serviço (Traefik, OpenPanel, etc.) se binding existir | Alta |
|
||||||
|
| **Diff vs. realidade** | Comparar matriz Spec 027 com `desk_users.role` + último login por SW | Média |
|
||||||
|
| **Export CSV/PDF** | Matriz completa para auditoria / onboarding | Média |
|
||||||
|
| **Filtro por produto** | Chips FOSSBilling, Odoo, OpenPanel, Carbonio… | Baixa |
|
||||||
|
| **Contas sistema** | Linha 🤖 `api_service` / agentes com credenciais mascaradas | Baixa |
|
||||||
|
| **Histórico** | Quem alterou role e quando (audit log Spec 004) | Futuro |
|
||||||
|
|
||||||
|
### Fase UI-B — Pós-aprovação
|
||||||
|
|
||||||
|
- Tab integrada em Administradores (sem flag)
|
||||||
|
- Export CSV/PDF da matriz
|
||||||
|
- Link «Ver matriz» no drawer Equipe
|
||||||
|
|
||||||
|
### Fase UI-C — Edição Agentics ✅ (staging)
|
||||||
|
|
||||||
|
- [x] Tabela SQLite `agent_role_bindings` + `agent_governance_caps`
|
||||||
|
- [x] `PATCH /api/v1/rbac/agent-bindings` (UI / focus / approve)
|
||||||
|
- [x] `PATCH /api/v1/rbac/agent-governance` (caps globais)
|
||||||
|
- [x] `GET /api/v1/rbac/agent-bindings/audit`
|
||||||
|
- [x] Enforcement API Agentics (`can_use_agentics_ui`, `can_chat_agent`, `can_trigger_runs`)
|
||||||
|
- [x] Toggles UI / OP / AP na matriz (flag `ACCESS_MATRIX_EDIT=1`)
|
||||||
|
- [x] Bindings A7→agentic_operator **locked** (Spec FR-027-007)
|
||||||
|
- [ ] Roger valida em staging → activar produção
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceite (Preview)
|
||||||
|
|
||||||
|
- [ ] Produção (`8091`): menu Matriz **invisível** com flag default
|
||||||
|
- [ ] Staging (`8192`): Roger vê Matriz como `super_admin`
|
||||||
|
- [ ] Página é 100% read-only (sem POST/PATCH)
|
||||||
|
- [ ] Dados batem com Spec 027 §3–6 e `platform_role_catalog.py`
|
||||||
|
- [ ] Equipe Ligbox / Mensagens / cadastro **inalterados**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Roger — revisar em http://10.10.10.122:8192 após deploy staging.*
|
||||||
Loading…
Reference in a new issue