diff --git a/BACKLOG.md b/BACKLOG.md index 8b77361..644f49c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -123,7 +123,8 @@ | **DESK-6** | P1 | Billing visibilidade πŸ’³ (023 Fase 1) | βœ… | | **INT-1** | P2 | OTRS API bridge β€” Spec 011 | πŸ“‹ | | **DESK-3** | P2 | Kanban, SLA (apΓ³s Spec 010) | πŸ“‹ β†’ Spec 008 | -| **AG-1** | P1 | Agentes IA + runbooks (Spec 029) | πŸ”„ staging | +| **AG-1** | P1 | Agentes IA + runbooks (Spec 029) | βœ… prod `0.9.7` | +| **AG-2** | P1 | Agentic Ops UI Mission Board (Spec 030) | βœ… prod | --- diff --git a/projects/ops-desk/api/app/agents/messages.py b/projects/ops-desk/api/app/agents/messages.py index cc01859..d70c4ed 100644 --- a/projects/ops-desk/api/app/agents/messages.py +++ b/projects/ops-desk/api/app/agents/messages.py @@ -111,6 +111,7 @@ def notify_finding_to_operators( severity: str, human_action: str, agent_id: str, + thread_id: int | None = None, ) -> int: """Abre thread + mensagem para operadores humanos.""" profile = resolve_agent(scenario_id, agent_id) @@ -120,20 +121,30 @@ def notify_finding_to_operators( "warn": "technician", }.get(severity, "technician") - existing = conn.execute( - "SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'", - (finding_id,), - ).fetchone() - if existing: - thread_id = existing["id"] + if thread_id: + tid = thread_id else: - thread_id = create_thread( - conn, - subject=title, - primary_agent=profile.id, - severity=severity, - related_finding_id=finding_id, - ) + existing = conn.execute( + "SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'", + (finding_id,), + ).fetchone() + if existing: + tid = existing["id"] + else: + inc = conn.execute( + "SELECT thread_id FROM agent_incidents WHERE scenario_id=? AND status='open'", + (scenario_id,), + ).fetchone() + if inc and inc["thread_id"]: + tid = inc["thread_id"] + else: + tid = create_thread( + conn, + subject=title, + primary_agent=profile.id, + severity=severity, + related_finding_id=finding_id, + ) agent_name = profile.name body = ( @@ -145,7 +156,7 @@ def notify_finding_to_operators( # Mensagem agente β†’ humanos (inbox operadores) post_message( conn, - thread_id=thread_id, + thread_id=tid, from_type="agent", from_id=profile.id, to_type="human", @@ -160,7 +171,7 @@ def notify_finding_to_operators( if profile.id not in ("A0", "orchestrator"): post_message( conn, - thread_id=thread_id, + thread_id=tid, from_type="agent", from_id="A0", to_type="agent", @@ -170,7 +181,7 @@ def notify_finding_to_operators( requires_human=False, ) - return thread_id + return tid def list_inbox(conn: sqlite3.Connection, *, role: str, limit: int = 50) -> list[dict]: @@ -267,7 +278,7 @@ def human_reply( # Copiloto (A6) ecoa confirmaΓ§Γ£o para o thread post_message( conn, - thread_id=thread_id, + thread_id=tid, from_type="agent", from_id="A6", to_type="human", diff --git a/projects/ops-desk/api/app/agents/routes.py b/projects/ops-desk/api/app/agents/routes.py index 8d8d790..19ce954 100644 --- a/projects/ops-desk/api/app/agents/routes.py +++ b/projects/ops-desk/api/app/agents/routes.py @@ -1,6 +1,7 @@ """Agentic API β€” Spec 029.""" from __future__ import annotations +import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query @@ -59,6 +60,73 @@ def agents_health(): } +@router.get("/overview") +def agents_overview(user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user) + return store.get_overview(conn) + + +@router.get("/incidents") +def agents_incidents( + user=Depends(auth.get_current_user), + conn=Depends(_db), + status: str = Query("open"), + severity: str | None = None, + agent_id: str | None = None, + limit: int = Query(50, ge=1, le=200), +): + _ops_view(user) + return {"incidents": store.list_incidents(conn, status=status, severity=severity, agent_id=agent_id, limit=limit)} + + +@router.get("/incidents/{incident_id}") +def agents_incident_detail(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user) + inc = store.get_incident(conn, incident_id) + if not inc: + raise HTTPException(404, "incident not found") + return { + "incident": inc, + "recent_runs": store.recent_runs_for_scenario(conn, inc["scenario_id"]), + "thread_id": inc.get("thread_id"), + } + + +@router.post("/incidents/{incident_id}/ack") +def ack_incident(incident_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)): + _ops_view(user) + inc = store.ack_incident(conn, incident_id, user.username) + if not inc: + raise HTTPException(404, "incident not found") + store.log_event(conn, event_type="incident.ack", message=f"#{incident_id}", payload={"by": user.username}) + conn.commit() + return {"ok": True, "incident_id": incident_id, "thread_id": inc.get("thread_id")} + + +@router.get("/timeline") +def agents_timeline(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int = Query(24, ge=1, le=100)): + _ops_view(user) + ticks = [ + dict(r) + for r in conn.execute( + """SELECT ts, message, payload_json FROM agent_action_log + WHERE event_type='tick.complete' ORDER BY id DESC LIMIT ?""", + (limit,), + ) + ] + out = [] + for t in ticks: + payload = {} + try: + payload = json.loads(t.get("payload_json") or "{}") + except json.JSONDecodeError: + pass + runs = payload.get("runs") or [] + findings = sum(r.get("findings_count", 0) for r in runs if isinstance(r, dict)) + out.append({"at": t["ts"], "scenarios": payload.get("total", len(runs)), "findings": findings}) + return {"ticks": out} + + @router.get("/roster") def agents_roster(user=Depends(auth.get_current_user)): _ops_view(user) diff --git a/projects/ops-desk/api/app/agents/runner.py b/projects/ops-desk/api/app/agents/runner.py index 8e26980..ee6f142 100644 --- a/projects/ops-desk/api/app/agents/runner.py +++ b/projects/ops-desk/api/app/agents/runner.py @@ -54,21 +54,34 @@ def run_scenario(conn, scenario_id, *, trigger="cron"): evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb]) fids.append(fid) if f.get("severity") in ("warn", "high", "critical"): - agent_messages.notify_finding_to_operators( + inc, notify_ops = store.upsert_incident( conn, - finding_id=fid, scenario_id=scenario_id, + finding_id=fid, title=f.get("title", "Finding"), severity=f.get("severity", "warn"), - human_action=human, - agent_id=agent_id, + primary_agent=agent_id, + suggested_human_action=human, ) + if notify_ops: + agent_messages.notify_finding_to_operators( + conn, + finding_id=fid, + scenario_id=scenario_id, + title=f.get("title", "Finding"), + severity=f.get("severity", "warn"), + human_action=human, + agent_id=agent_id, + thread_id=inc.get("thread_id"), + ) if f.get("severity") in ("high", "critical"): notify.notify_finding({**f, "suggested_human_action": human}) store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid}, agent_id=agent_id) status = "ok" if not raw else "degraded" store.finish_run(conn, run_id, status=status, summary=f"{len(raw)} finding(s)" if raw else "healthy") store.log_event(conn, event_type="run.finish", message=status, run_id=run_id, agent_id=agent_id) + if not raw: + store.resolve_incidents_for_scenario(conn, scenario_id) conn.commit() return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids} diff --git a/projects/ops-desk/api/app/agents/store.py b/projects/ops-desk/api/app/agents/store.py index d44c09c..6424c6a 100644 --- a/projects/ops-desk/api/app/agents/store.py +++ b/projects/ops-desk/api/app/agents/store.py @@ -40,7 +40,28 @@ def init_agent_schema(conn): CREATE TABLE IF NOT EXISTS agent_kb_chunks ( id INTEGER PRIMARY KEY, source_path TEXT NOT NULL, chunk_text TEXT NOT NULL, indexed_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_agent_runs_scenario ON agent_runs(scenario_id); + CREATE TABLE IF NOT EXISTS agent_incidents ( + id INTEGER PRIMARY KEY, + scenario_id TEXT NOT NULL UNIQUE, + primary_agent TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + title TEXT NOT NULL, + latest_finding_id INTEGER, + occurrence_count INTEGER NOT NULL DEFAULT 1, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + suggested_human_action TEXT, + thread_id INTEGER, + acknowledged_at TEXT, + acknowledged_by TEXT); + CREATE INDEX IF NOT EXISTS idx_agent_incidents_status ON agent_incidents(status); """) + try: + if conn.execute("SELECT COUNT(*) FROM agent_incidents").fetchone()[0] == 0: + backfill_incidents(conn) + except sqlite3.OperationalError: + pass def log_event(conn, *, event_type, message, agent_id="sentinel", run_id=None, payload=None): conn.execute("INSERT INTO agent_action_log (ts,agent_id,run_id,event_type,message,payload_json) VALUES (?,?,?,?,?,?)", @@ -115,3 +136,231 @@ def search_kb(conn, query, limit=8): if score: scored.append((score, {"source": row["source_path"], "snippet": row["chunk_text"][:400]})) scored.sort(key=lambda x: -x[0]) return [s[1] for s in scored[:limit]] + + +SEVERITY_RANK = {"info": 0, "warn": 1, "high": 2, "critical": 3} + + +def _severity_max(a: str, b: str) -> str: + return a if SEVERITY_RANK.get(a, 0) >= SEVERITY_RANK.get(b, 0) else b + + +def _incident_row(conn, incident_id: int) -> dict | None: + row = conn.execute("SELECT * FROM agent_incidents WHERE id=?", (incident_id,)).fetchone() + return dict(row) if row else None + + +def _enrich_incident(conn, row: dict) -> dict: + from app.agents.catalog import AGENT_CATALOG + + item = dict(row) + p = AGENT_CATALOG.get(item.get("primary_agent", "")) + item["agent_name"] = p.name if p else item.get("primary_agent", "Vigia") + return item + + +def upsert_incident( + conn, + *, + scenario_id: str, + finding_id: int, + title: str, + severity: str, + primary_agent: str, + suggested_human_action: str = "", +) -> tuple[dict, bool]: + """Retorna (incidente, notify_operators). notify=True na 1Βͺ ocorrΓͺncia ou escalaΓ§Γ£o.""" + from app.agents import messages as agent_messages + + now = _now() + row = conn.execute("SELECT * FROM agent_incidents WHERE scenario_id=?", (scenario_id,)).fetchone() + if row: + item = dict(row) + prev_sev = item["severity"] + new_sev = _severity_max(prev_sev, severity) + was_closed = item["status"] != "open" + notify = was_closed or SEVERITY_RANK.get(new_sev, 0) > SEVERITY_RANK.get(prev_sev, 0) + if was_closed: + conn.execute( + """UPDATE agent_incidents SET occurrence_count=occurrence_count+1, last_seen_at=?, + latest_finding_id=?, severity=?, title=?, suggested_human_action=?, + primary_agent=?, status='open', acknowledged_at=NULL, acknowledged_by=NULL WHERE id=?""", + (now, finding_id, new_sev, title, suggested_human_action or item.get("suggested_human_action"), primary_agent, item["id"]), + ) + else: + conn.execute( + """UPDATE agent_incidents SET occurrence_count=occurrence_count+1, last_seen_at=?, + latest_finding_id=?, severity=?, title=?, suggested_human_action=?, + primary_agent=? WHERE id=?""", + (now, finding_id, new_sev, title, suggested_human_action or item.get("suggested_human_action"), primary_agent, item["id"]), + ) + out = _incident_row(conn, item["id"]) + if out and not out.get("thread_id"): + thread_id = agent_messages.create_thread( + conn, subject=title, primary_agent=primary_agent, severity=new_sev, related_finding_id=finding_id + ) + conn.execute("UPDATE agent_incidents SET thread_id=? WHERE id=?", (thread_id, item["id"])) + out = _incident_row(conn, item["id"]) + return _enrich_incident(conn, out or {}), notify + + thread_id = agent_messages.create_thread( + conn, subject=title, primary_agent=primary_agent, severity=severity, related_finding_id=finding_id + ) + iid = int( + conn.execute( + """INSERT INTO agent_incidents + (scenario_id, primary_agent, severity, status, title, latest_finding_id, + occurrence_count, first_seen_at, last_seen_at, suggested_human_action, thread_id) + VALUES (?,?,?,?,?,?,1,?,?,?,?)""", + (scenario_id, primary_agent, severity, "open", title, finding_id, now, now, suggested_human_action, thread_id), + ).lastrowid + ) + return _enrich_incident(conn, _incident_row(conn, iid) or {}), True + + +def list_incidents( + conn, + *, + status: str = "open", + severity: str | None = None, + agent_id: str | None = None, + limit: int = 50, +) -> list[dict]: + q = "SELECT * FROM agent_incidents WHERE 1=1" + params: list[Any] = [] + if status != "all": + q += " AND status=?" + params.append(status) + if severity: + q += " AND severity=?" + params.append(severity) + if agent_id: + q += " AND primary_agent=?" + params.append(agent_id) + q += " ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'warn' THEN 2 ELSE 3 END, last_seen_at DESC LIMIT ?" + params.append(limit) + return [_enrich_incident(conn, dict(r)) for r in conn.execute(q, params)] + + +def get_incident(conn, incident_id: int) -> dict | None: + row = conn.execute("SELECT * FROM agent_incidents WHERE id=?", (incident_id,)).fetchone() + return _enrich_incident(conn, dict(row)) if row else None + + +def ack_incident(conn, incident_id: int, username: str) -> dict | None: + inc = get_incident(conn, incident_id) + if not inc: + return None + now = _now() + conn.execute( + "UPDATE agent_incidents SET status='ack', acknowledged_at=?, acknowledged_by=? WHERE id=?", + (now, username, incident_id), + ) + if inc.get("latest_finding_id"): + conn.execute( + "UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=? AND acknowledged_at IS NULL", + (now, username, inc["latest_finding_id"]), + ) + if inc.get("thread_id"): + conn.execute( + """UPDATE agent_messages SET acknowledged_at=?, acknowledged_by=? + WHERE thread_id=? AND requires_human=1 AND acknowledged_at IS NULL""", + (now, username, inc["thread_id"]), + ) + return get_incident(conn, incident_id) + + +def resolve_incidents_for_scenario(conn, scenario_id: str) -> None: + """Marca incidente resolvido quando cenΓ‘rio volta a OK.""" + conn.execute( + "UPDATE agent_incidents SET status='resolved', last_seen_at=? WHERE scenario_id=? AND status='open'", + (_now(), scenario_id), + ) + + +def get_overview(conn) -> dict: + import os + + from app.agents import llm_client + + last_tick = conn.execute( + "SELECT ts, message, payload_json FROM agent_action_log WHERE event_type='tick.complete' ORDER BY id DESC LIMIT 1" + ).fetchone() + open_counts = {r["severity"]: r["c"] for r in conn.execute( + "SELECT severity, COUNT(*) c FROM agent_incidents WHERE status='open' GROUP BY severity" + )} + scenarios = list_scenarios(conn) + ok_count = sum(1 for s in scenarios if s.get("last_run_status") == "ok") + payload = {} + if last_tick and last_tick["payload_json"]: + try: + payload = json.loads(last_tick["payload_json"]) + except json.JSONDecodeError: + pass + return { + "tier": "t1" if llm_client.AGENTIC_LLM_ENABLED else "t0", + "ollama": llm_client.ollama_available(), + "model": llm_client.AGENTIC_LLM_MODEL, + "last_tick_at": last_tick["ts"] if last_tick else None, + "last_tick_status": "degraded" if payload.get("runs") and any( + r.get("findings_count", 0) > 0 for r in payload.get("runs", []) if isinstance(r, dict) + ) else "ok", + "scenarios_total": len(scenarios), + "scenarios_ok": ok_count, + "incidents_open": { + "critical": open_counts.get("critical", 0), + "high": open_counts.get("high", 0), + "warn": open_counts.get("warn", 0), + "info": open_counts.get("info", 0), + }, + "worker_interval_sec": int(os.getenv("AGENTIC_INTERVAL_SEC", "600")), + } + + +def recent_runs_for_scenario(conn, scenario_id: str, limit: int = 12) -> list[dict]: + return [ + dict(r) + for r in conn.execute( + """SELECT id AS run_id, status, started_at, summary_text, + (SELECT COUNT(*) FROM agent_findings f WHERE f.run_id=agent_runs.id) AS findings_count + FROM agent_runs WHERE scenario_id=? ORDER BY id DESC LIMIT ?""", + (scenario_id, limit), + ) + ] + + +def backfill_incidents(conn) -> int: + """Consolida findings abertos legacy em agent_incidents (one-time safe).""" + rows = conn.execute( + """SELECT f.id AS finding_id, f.severity, f.title, f.suggested_human_action, f.created_at, + r.scenario_id + FROM agent_findings f + JOIN agent_runs r ON r.id = f.run_id + WHERE f.acknowledged_at IS NULL + ORDER BY f.id ASC""" + ).fetchall() + seen: set[str] = set() + n = 0 + for row in rows: + sid = row["scenario_id"] + if sid in seen: + continue + seen.add(sid) + existing = conn.execute("SELECT id FROM agent_incidents WHERE scenario_id=?", (sid,)).fetchone() + if existing: + continue + from app.agents.catalog import SCENARIO_AGENT_MAP + + agent_id = SCENARIO_AGENT_MAP.get(sid, "sentinel") + upsert_incident( + conn, + scenario_id=sid, + finding_id=row["finding_id"], + title=row["title"], + severity=row["severity"], + primary_agent=agent_id, + suggested_human_action=row["suggested_human_action"] or "", + ) + n += 1 + conn.commit() + return n diff --git a/projects/ops-desk/api/tests/test_agents_030.py b/projects/ops-desk/api/tests/test_agents_030.py new file mode 100644 index 0000000..4934746 --- /dev/null +++ b/projects/ops-desk/api/tests/test_agents_030.py @@ -0,0 +1,78 @@ +"""Tests Agentic Ops UI β€” Spec 030.""" +from __future__ import annotations + +import sqlite3 + +from app.agents import store + + +def _mem_conn(): + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + store.init_agent_schema(conn) + store.sync_registry = lambda c: None # noqa: patch below + for sc in [ + {"id": "desk.api.health", "title": "Desk", "severity_default": "warn"}, + {"id": "vm123.finance.stack", "title": "FOSS", "severity_default": "high"}, + ]: + store.upsert_scenario(conn, sc) + conn.commit() + return conn + + +def test_agent_incidents_table(): + conn = sqlite3.connect(":memory:") + store.init_agent_schema(conn) + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "agent_incidents" in tables + + +def test_upsert_incident_deduplicates_scenario(): + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + store.init_agent_schema(conn) + store.upsert_scenario(conn, {"id": "test.scenario", "title": "Test", "severity_default": "warn"}) + run_id = store.create_run(conn, "test.scenario", "test") + f1 = store.add_finding(conn, run_id, severity="warn", category="api", title="Alert A", human_action="fix A") + inc1, n1 = store.upsert_incident( + conn, scenario_id="test.scenario", finding_id=f1, title="Alert A", + severity="warn", primary_agent="sentinel", suggested_human_action="fix A", + ) + f2 = store.add_finding(conn, run_id, severity="warn", category="api", title="Alert A", human_action="fix A") + inc2, n2 = store.upsert_incident( + conn, scenario_id="test.scenario", finding_id=f2, title="Alert A", + severity="warn", primary_agent="sentinel", suggested_human_action="fix A", + ) + assert inc1["id"] == inc2["id"] + assert inc2["occurrence_count"] == 2 + assert n1 is True + assert n2 is False + assert len(store.list_incidents(conn, status="open")) == 1 + + +def test_ack_incident_closes_open(): + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + store.init_agent_schema(conn) + store.upsert_scenario(conn, {"id": "x.scenario", "title": "X", "severity_default": "high"}) + run_id = store.create_run(conn, "x.scenario", "test") + fid = store.add_finding(conn, run_id, severity="high", category="api", title="Down", human_action="check") + store.upsert_incident( + conn, scenario_id="x.scenario", finding_id=fid, title="Down", + severity="high", primary_agent="sentinel", suggested_human_action="check", + ) + inc = store.list_incidents(conn)[0] + store.ack_incident(conn, inc["id"], "root") + conn.commit() + assert store.list_incidents(conn, status="open") == [] + + +def test_overview_structure(): + conn = sqlite3.connect(":memory:", check_same_thread=False) + conn.row_factory = sqlite3.Row + store.init_agent_schema(conn) + store.upsert_scenario(conn, {"id": "desk.api.health", "title": "Desk", "severity_default": "warn"}) + ov = store.get_overview(conn) + assert "tier" in ov + assert "incidents_open" in ov + assert "scenarios_total" in ov diff --git a/projects/ops-desk/frontend/assets/agentic-ops.css b/projects/ops-desk/frontend/assets/agentic-ops.css new file mode 100644 index 0000000..bed8652 --- /dev/null +++ b/projects/ops-desk/frontend/assets/agentic-ops.css @@ -0,0 +1,178 @@ +/* Spec 030 β€” Agentic Ops Mission Board */ +.ao-shell { + display: grid; + grid-template-columns: 200px 1fr minmax(300px, 360px); + gap: 1rem; + margin-top: 0.5rem; + align-items: start; +} +.ao-status-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 0.5rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border, #333); +} +.ao-status-metrics { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + font-size: 0.82rem; + color: var(--muted, #94a3b8); +} +.ao-fleet-rail { + position: sticky; + top: 0.5rem; +} +.ao-fleet-rail h3 { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted, #888); + margin-bottom: 0.5rem; +} +.ao-fleet-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + margin-bottom: 0.25rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + border: 1px solid transparent; +} +.ao-fleet-item:hover { background: rgba(255, 255, 255, 0.04); } +.ao-fleet-item--active { border-color: #3b82f6; background: rgba(59, 130, 246, 0.1); } +.ao-fleet-item--pulse .ao-fleet-dot { animation: ao-pulse 1.5s ease-in-out infinite; } +.ao-fleet-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #64748b; + flex-shrink: 0; +} +.ao-fleet-dot--active { background: #22c55e; } +@keyframes ao-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.3); } +} +.ao-board { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)); + gap: 0.65rem; + min-height: 200px; +} +.ao-board-col h4 { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.5rem; + padding-bottom: 0.35rem; + border-bottom: 2px solid var(--border, #444); +} +.ao-board-col--critical h4 { border-color: #ef4444; color: #fca5a5; } +.ao-board-col--high h4 { border-color: #f97316; color: #fdba74; } +.ao-board-col--warn h4 { border-color: #eab308; color: #fde047; } +.ao-board-col--ok h4 { border-color: #64748b; color: #94a3b8; } +.ao-incident-card { + padding: 0.65rem 0.75rem; + margin-bottom: 0.5rem; + border-radius: 8px; + border: 1px solid var(--border, #333); + background: rgba(0, 0, 0, 0.15); + cursor: pointer; + min-height: 120px; + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.ao-incident-card:hover { border-color: #3b82f6; } +.ao-incident-card--active { + border-color: #3b82f6; + box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.4); + background: rgba(59, 130, 246, 0.08); +} +.ao-incident-title { font-weight: 600; font-size: 0.88rem; line-height: 1.3; } +.ao-incident-meta { font-size: 0.72rem; color: var(--muted, #888); } +.ao-incident-action { + font-size: 0.78rem; + color: var(--muted, #aaa); + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.ao-incident-actions { display: flex; gap: 0.35rem; margin-top: auto; flex-wrap: wrap; } +.ao-context-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 420px; + position: sticky; + top: 0.5rem; +} +.ao-thread-messages { + flex: 1; + overflow-y: auto; + max-height: 280px; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.12); + border-radius: 6px; + font-size: 0.85rem; +} +.ao-bubble { margin: 0.4rem 0; padding: 0.5rem 0.65rem; border-radius: 8px; } +.ao-bubble-agent { background: rgba(59, 130, 246, 0.12); border-left: 3px solid #3b82f6; } +.ao-bubble-human { background: rgba(34, 197, 94, 0.1); border-left: 3px solid #22c55e; } +.ao-bubble-meta { font-size: 0.7rem; color: var(--muted, #888); margin-bottom: 0.2rem; } +.ao-timeline { + margin-top: 1rem; + padding: 0.65rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.1); + border: 1px solid var(--border, #333); +} +.ao-timeline-row { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + padding: 0.25rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.ao-mobile-tabs { display: none; } +.ao-empty { + text-align: center; + padding: 2rem 1rem; + color: var(--muted, #888); + grid-column: 1 / -1; +} +.ao-skeleton { + height: 100px; + border-radius: 8px; + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); + background-size: 200% 100%; + animation: ao-shimmer 1.2s infinite; +} +@keyframes ao-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +@media (max-width: 1100px) { + .ao-shell { grid-template-columns: 1fr; } + .ao-mobile-tabs { + display: flex; + gap: 0.35rem; + margin-bottom: 0.75rem; + } + .ao-mobile-tabs button.active { background: rgba(59, 130, 246, 0.2); border-color: #3b82f6; } + .ao-pane { display: none; } + .ao-pane--active { display: block; } + .ao-fleet-rail, .ao-context-panel { position: static; } + .ao-board { grid-template-columns: 1fr 1fr; } +} diff --git a/projects/ops-desk/frontend/assets/agentic-ops.js b/projects/ops-desk/frontend/assets/agentic-ops.js index 9fc708a..28427ab 100644 --- a/projects/ops-desk/frontend/assets/agentic-ops.js +++ b/projects/ops-desk/frontend/assets/agentic-ops.js @@ -1,8 +1,24 @@ (function () { - const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>'); - let state = { threadId: null, selectedAgent: 'A6' }; + const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>'); + const SEV_COLS = [ + { key: 'critical', label: 'CrΓ­tico', cls: 'ao-board-col--critical' }, + { key: 'high', label: 'Alto', cls: 'ao-board-col--high' }, + { key: 'warn', label: 'Aviso', cls: 'ao-board-col--warn' }, + { key: 'info', label: 'Info / OK', cls: 'ao-board-col--ok' }, + ]; + const AGENT_ACCENTS = { + A0: '#6366f1', A1: '#22c55e', A2: '#3b82f6', A3: '#06b6d4', A4: '#8b5cf6', + A5: '#ec4899', A6: '#a855f7', A7: '#ef4444', sentinel: '#f59e0b', curator: '#64748b', + }; + + let state = { + selectedAgent: 'A6', + selectedIncidentId: null, + threadId: null, + mobileTab: 'board', + pollTimer: null, + }; - /** Usa o helper global do Desk (app.js) β€” garante JWT igual aos outros mΓ³dulos. */ async function agentsApi(path, opts = {}) { const deskApi = typeof globalThis.api === 'function' ? globalThis.api : null; if (deskApi) return deskApi(`/v1/agents${path}`, opts); @@ -17,59 +33,159 @@ return r.json(); } - function agentCard(a) { - const active = state.selectedAgent === a.id ? ' agentic-agent-active' : ''; - return `
-

${esc(a.name)} ${esc(a.id)}

-

${esc(a.role)}

- -

AprovaΓ§Γ£o: ${esc(a.approval)}

+ function fmtAge(iso) { + if (!iso) return 'β€”'; + try { + const ms = Date.now() - new Date(iso).getTime(); + const m = Math.floor(ms / 60000); + if (m < 1) return 'agora'; + if (m < 60) return `hΓ‘ ${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `hΓ‘ ${h}h`; + return `hΓ‘ ${Math.floor(h / 24)}d`; + } catch { + return iso; + } + } + + function incidentCard(inc) { + const active = state.selectedIncidentId === inc.id ? ' ao-incident-card--active' : ''; + return `
+
${esc(inc.title)}
+
${esc(inc.agent_name)} Β· ${esc(inc.scenario_id)} Β· ${fmtAge(inc.last_seen_at)} Β· ${inc.occurrence_count || 1}Γ—
+
${esc((inc.suggested_human_action || 'Investigar manualmente.').slice(0, 160))}
+
+ + +
`; } - function inboxRow(m) { - return `
-
- ${esc(m.agent_name || m.from_id)} - ${esc(m.thread_severity || 'info')} -
-

${esc(m.thread_subject || m.message)} Β· ${esc(m.created_at)}

-

${esc((m.body || '').slice(0, 280))}

-
- - -
-
`; + function fleetItem(a, openAgents) { + const active = state.selectedAgent === a.id ? ' ao-fleet-item--active' : ''; + const pulse = openAgents.has(a.id) ? ' ao-fleet-item--pulse' : ''; + const dotCls = openAgents.has(a.id) ? ' ao-fleet-dot--active' : ''; + const accent = AGENT_ACCENTS[a.id] || '#64748b'; + return `
+ + ${esc(a.name)} + ${esc(a.id)} +
`; } function threadBubble(m) { - const isHuman = m.from_type === 'human'; - const cls = isHuman ? 'agentic-bubble-human' : 'agentic-bubble-agent'; - return `
-
${esc(m.from_label || m.from_id)} Β· ${esc(m.created_at)}
-
${esc(m.body).replace(/\n/g, '
')}
+ const cls = m.from_type === 'human' ? 'ao-bubble-human' : 'ao-bubble-agent'; + return `
+
${esc(m.from_label || m.from_id)} Β· ${esc(m.created_at)}
+
${esc(m.body).replace(/\n/g, '
')}
`; } async function loadThread(el, threadId) { state.threadId = threadId; - const box = el.querySelector('#agentic-thread-messages'); - if (!box) return; - box.innerHTML = '

Carregando thread…

'; + const box = el.querySelector('#ao-thread-messages'); + if (!box || !threadId) return; + box.innerHTML = '

Carregando…

'; const data = await agentsApi(`/threads/${threadId}/messages`); box.innerHTML = data.messages.map(threadBubble).join('') || '

Sem mensagens.

'; box.scrollTop = box.scrollHeight; } - async function renderAgenticOps() { + function bindShell(el) { + el.querySelector('#ao-btn-refresh')?.addEventListener('click', () => renderAgenticOps()); + el.querySelectorAll('[data-agent-id]').forEach((node) => { + node.addEventListener('click', () => { + state.selectedAgent = node.dataset.agentId; + renderAgenticOps(); + }); + }); + el.querySelectorAll('[data-incident-id], [data-open-incident]').forEach((node) => { + node.addEventListener('click', async (ev) => { + if (ev.target.closest('[data-ack-incident]')) return; + const id = parseInt(node.dataset.incidentId || node.dataset.openIncident, 10); + state.selectedIncidentId = id; + const card = el.querySelector(`[data-incident-id="${id}"]`); + const tid = parseInt(card?.dataset.threadId, 10); + if (tid) await loadThread(el, tid); + renderAgenticOps(); + }); + }); + el.querySelectorAll('[data-ack-incident]').forEach((btn) => { + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + await agentsApi(`/incidents/${btn.dataset.ackIncident}/ack`, { method: 'POST' }); + if (state.selectedIncidentId === parseInt(btn.dataset.ackIncident, 10)) { + state.selectedIncidentId = null; + state.threadId = null; + } + await renderAgenticOps(); + }); + }); + el.querySelector('#ao-btn-reply')?.addEventListener('click', async () => { + const input = el.querySelector('#ao-reply-input'); + const tid = state.threadId; + const body = (input?.value || '').trim(); + if (!tid || !body) return; + await agentsApi(`/threads/${tid}/reply`, { + method: 'POST', + body: JSON.stringify({ body, target_agent: state.selectedAgent }), + }); + input.value = ''; + await loadThread(el, tid); + }); + el.querySelector('#ao-btn-chat')?.addEventListener('click', async () => { + const input = el.querySelector('#ao-chat-input'); + const out = el.querySelector('#ao-chat-answer'); + const q = (input?.value || '').trim(); + if (!q) return; + out.hidden = false; + out.innerHTML = '

A pensar…

'; + try { + const res = await agentsApi('/chat', { + method: 'POST', + body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }), + }); + out.innerHTML = `

${esc(state.selectedAgent)} (${esc(res.model)})

${esc(res.answer)}

`; + if (res.thread_id) { + state.threadId = res.thread_id; + await loadThread(el, res.thread_id); + } + } catch (err) { + out.innerHTML = `

${esc(err.message)}

`; + } + }); + el.querySelectorAll('.ao-mobile-tabs button').forEach((btn) => { + btn.addEventListener('click', () => { + state.mobileTab = btn.dataset.aoTab; + el.querySelectorAll('.ao-mobile-tabs button').forEach((b) => b.classList.toggle('active', b === btn)); + el.querySelectorAll('.ao-pane').forEach((p) => { + p.classList.toggle('ao-pane--active', p.dataset.aoPane === state.mobileTab); + }); + }); + }); + } + + function schedulePoll() { + if (state.pollTimer) clearInterval(state.pollTimer); + state.pollTimer = setInterval(() => { + if (document.hidden) return; + const view = document.getElementById('view-agentic-ops'); + if (view && !view.hidden) renderAgenticOps({ poll: true }); + }, 30000); + } + + async function renderAgenticOps(options = {}) { const el = document.getElementById('agentic-ops-content'); if (!el) return; - el.innerHTML = '

Carregando Agentic Ops…

'; + if (!options.poll) { + el.innerHTML = '

Carregando Mission Board…

' + + '
'; + } if (!getToken()) { - el.innerHTML = '

SessΓ£o nΓ£o encontrada neste endereΓ§o. Fazer login (use sempre o mesmo URL β€” ex. desk.ligbox.com.br).

'; + el.innerHTML = '

SessΓ£o nΓ£o encontrada. Fazer login

'; return; } - if (typeof ensureValidSession === 'function') { + if (!options.poll && typeof ensureValidSession === 'function') { const ok = await ensureValidSession(); if (!ok) { el.innerHTML = '

SessΓ£o expirada. Fazer login

'; @@ -77,137 +193,102 @@ } } try { - const [health, roster, inbox, threads, findings] = await Promise.all([ - agentsApi('/health'), + const [overview, incidents, roster, timeline] = await Promise.all([ + agentsApi('/overview'), + agentsApi('/incidents?status=open&limit=50'), agentsApi('/roster'), - agentsApi('/inbox?limit=20'), - agentsApi('/threads?limit=15'), - agentsApi('/findings?limit=15'), + agentsApi('/timeline?limit=12'), ]); - const tier = health.tier === 't1' ? 'T1 LLM' : 'T0'; - const ollama = health.ollama - ? `Ollama Β· ${esc(health.model)}` + const list = incidents.incidents || []; + const openAgents = new Set(list.map((i) => i.primary_agent)); + const filtered = state.selectedAgent && state.selectedAgent !== 'ALL' + ? list.filter((i) => i.primary_agent === state.selectedAgent) + : list; + const bySev = { critical: [], high: [], warn: [], info: [] }; + filtered.forEach((inc) => { + const k = bySev[inc.severity] ? inc.severity : 'warn'; + (bySev[k] || bySev.warn).push(inc); + }); + const sel = list.find((i) => i.id === state.selectedIncidentId); + const ollamaPill = overview.ollama + ? `Ollama Β· ${esc(overview.model)}` : 'Ollama offline'; - const agents = roster.agents || []; - const inboxItems = inbox.messages || []; - const threadOpts = (threads.threads || []).map(t => - `` - ).join(''); - const fRows = (findings.findings || []).map(f => - `
  • ${esc(f.title)} ${esc(f.severity)} - ${f.suggested_human_action ? `
    ${esc(f.suggested_human_action)}` : ''}
  • ` - ).join('') || '
  • Nenhum finding aberto.
  • '; + const tier = overview.tier === 't1' ? 'T1 LLM' : 'T0'; + const open = overview.incidents_open || {}; + const boardCols = SEV_COLS.map((col) => { + const cards = (bySev[col.key] || []).map(incidentCard).join('') + || '

    β€”

    '; + return `

    ${col.label}

    ${cards}
    `; + }).join(''); + const fleet = (roster.agents || []).map((a) => fleetItem(a, openAgents)).join(''); + const ticks = (timeline.ticks || []).map((t) => + `
    ${esc(fmtAge(t.at))}${t.scenarios || 'β€”'} cenΓ‘rios Β· ${t.findings || 0} findings
    ` + ).join('') || '

    Sem ticks recentes.

    '; el.innerHTML = ` - -
    -

    Agentic Ops

    Spec 029 Β· ${tier} ${ollama} Β· ${inboxItems.length} pendente(s)

    - -
    -
    -
    -

    Agentes (A0–A7)

    -

    Clique para seleccionar destino do chat.

    - ${agents.map(agentCard).join('')} -
    -
    -

    Inbox operadores

    -

    Mensagens dos agentes que exigem acΓ§Γ£o humana.

    - ${inboxItems.length ? inboxItems.map(inboxRow).join('') : '

    Inbox vazia.

    '} -

    Findings abertos

    -
      ${fRows}
    -
    -
    -

    Janela de contexto

    - -

    Seleccione uma thread ou abra da inbox.

    - - -
    -

    Chat Copiloto (${esc(state.selectedAgent)})

    -
    - - +
    +
    +

    Agentic Ops

    +
    + ${tier} ${ollamaPill} + Último tick: ${fmtAge(overview.last_tick_at)} + ${overview.scenarios_ok || 0}/${overview.scenarios_total || 9} cenÑrios OK + Abertos: ${open.high || 0} alto · ${open.warn || 0} aviso
    - +
    + +
    +
    + + + +
    +
    + +
    +
    ${filtered.length ? boardCols : '

    βœ“ Ambiente saudΓ‘vel β€” nenhum incidente aberto.

    '}
    +

    Timeline ticks (24h)

    ${ticks}
    +
    `; - el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps); - el.querySelectorAll('.agentic-agent-card').forEach(card => { - card.addEventListener('click', () => { - state.selectedAgent = card.dataset.agentId; - renderAgenticOps(); - }); - }); - el.querySelectorAll('[data-open-thread]').forEach(btn => { - btn.addEventListener('click', () => loadThread(el, parseInt(btn.dataset.openThread, 10))); - }); - el.querySelectorAll('[data-ack-msg]').forEach(btn => { - btn.addEventListener('click', async () => { - await agentsApi(`/messages/${btn.dataset.ackMsg}/ack`, { method: 'POST' }); - await renderAgenticOps(); - }); - }); - el.querySelector('#agentic-thread-select')?.addEventListener('change', (e) => { - const id = parseInt(e.target.value, 10); - if (id) loadThread(el, id); - }); - el.querySelector('#btn-agentic-reply')?.addEventListener('click', async () => { - const input = el.querySelector('#agentic-reply-input'); - const tid = state.threadId || parseInt(el.querySelector('#agentic-thread-select')?.value, 10); - const body = (input?.value || '').trim(); - if (!tid || !body) return; - await agentsApi(`/threads/${tid}/reply`, { - method: 'POST', - body: JSON.stringify({ body, target_agent: state.selectedAgent }), - }); - input.value = ''; - await loadThread(el, tid); - }); - el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => { - const input = el.querySelector('#agentic-chat-input'); - const out = el.querySelector('#agentic-chat-answer'); - const q = (input?.value || '').trim(); - if (!q) return; - out.hidden = false; - out.innerHTML = '

    A pensar…

    '; - try { - const res = await agentsApi('/chat', { - method: 'POST', - body: JSON.stringify({ question: q, include_findings: true, target_agent: state.selectedAgent }), - }); - out.innerHTML = `

    ${esc(state.selectedAgent)} (${esc(res.model)})

    ${esc(res.answer)}

    `; - if (res.thread_id) { - state.threadId = res.thread_id; - await loadThread(el, res.thread_id); - } - } catch (err) { - out.innerHTML = `

    ${esc(err.message)}

    `; - } - }); + bindShell(el); if (state.threadId) await loadThread(el, state.threadId); + else if (sel?.thread_id) await loadThread(el, sel.thread_id); + if (!state.pollTimer) schedulePoll(); } catch (err) { - el.innerHTML = `

    Erro: ${esc(err.message)}

    `; + if (!options.poll) el.innerHTML = `

    Erro: ${esc(err.message)}

    `; } } + + document.addEventListener('keydown', (ev) => { + if (ev.key === 'r' && !ev.ctrlKey && !ev.metaKey && document.getElementById('view-agentic-ops') && !document.getElementById('view-agentic-ops').hidden) { + const t = ev.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return; + ev.preventDefault(); + renderAgenticOps(); + } + if (ev.key === 'Escape') { + state.selectedIncidentId = null; + state.threadId = null; + renderAgenticOps(); + } + }); + window.renderAgenticOps = renderAgenticOps; })(); diff --git a/projects/ops-desk/frontend/index.html b/projects/ops-desk/frontend/index.html index 5cba83e..ac6e7a0 100644 --- a/projects/ops-desk/frontend/index.html +++ b/projects/ops-desk/frontend/index.html @@ -6,6 +6,7 @@ Ligbox Ops β€” Support Desk +