Implement Spec 030 Agentic Ops Mission Board (UI-A/B/C).
Add agent_incidents dedup, overview/incidents/timeline API, mission board UI with fleet rail, kanban, context panel, mobile tabs, poll and keyboard shortcuts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1e8deb23ec
commit
fd491e5859
13 changed files with 1450 additions and 178 deletions
|
|
@ -123,7 +123,8 @@
|
||||||
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
|
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
|
||||||
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
|
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
|
||||||
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
|
| **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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ def notify_finding_to_operators(
|
||||||
severity: str,
|
severity: str,
|
||||||
human_action: str,
|
human_action: str,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
|
thread_id: int | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Abre thread + mensagem para operadores humanos."""
|
"""Abre thread + mensagem para operadores humanos."""
|
||||||
profile = resolve_agent(scenario_id, agent_id)
|
profile = resolve_agent(scenario_id, agent_id)
|
||||||
|
|
@ -120,14 +121,24 @@ def notify_finding_to_operators(
|
||||||
"warn": "technician",
|
"warn": "technician",
|
||||||
}.get(severity, "technician")
|
}.get(severity, "technician")
|
||||||
|
|
||||||
|
if thread_id:
|
||||||
|
tid = thread_id
|
||||||
|
else:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'",
|
"SELECT id FROM agent_threads WHERE related_finding_id=? AND status='open'",
|
||||||
(finding_id,),
|
(finding_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
thread_id = existing["id"]
|
tid = existing["id"]
|
||||||
else:
|
else:
|
||||||
thread_id = create_thread(
|
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,
|
conn,
|
||||||
subject=title,
|
subject=title,
|
||||||
primary_agent=profile.id,
|
primary_agent=profile.id,
|
||||||
|
|
@ -145,7 +156,7 @@ def notify_finding_to_operators(
|
||||||
# Mensagem agente → humanos (inbox operadores)
|
# Mensagem agente → humanos (inbox operadores)
|
||||||
post_message(
|
post_message(
|
||||||
conn,
|
conn,
|
||||||
thread_id=thread_id,
|
thread_id=tid,
|
||||||
from_type="agent",
|
from_type="agent",
|
||||||
from_id=profile.id,
|
from_id=profile.id,
|
||||||
to_type="human",
|
to_type="human",
|
||||||
|
|
@ -160,7 +171,7 @@ def notify_finding_to_operators(
|
||||||
if profile.id not in ("A0", "orchestrator"):
|
if profile.id not in ("A0", "orchestrator"):
|
||||||
post_message(
|
post_message(
|
||||||
conn,
|
conn,
|
||||||
thread_id=thread_id,
|
thread_id=tid,
|
||||||
from_type="agent",
|
from_type="agent",
|
||||||
from_id="A0",
|
from_id="A0",
|
||||||
to_type="agent",
|
to_type="agent",
|
||||||
|
|
@ -170,7 +181,7 @@ def notify_finding_to_operators(
|
||||||
requires_human=False,
|
requires_human=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
return thread_id
|
return tid
|
||||||
|
|
||||||
|
|
||||||
def list_inbox(conn: sqlite3.Connection, *, role: str, limit: int = 50) -> list[dict]:
|
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
|
# Copiloto (A6) ecoa confirmação para o thread
|
||||||
post_message(
|
post_message(
|
||||||
conn,
|
conn,
|
||||||
thread_id=thread_id,
|
thread_id=tid,
|
||||||
from_type="agent",
|
from_type="agent",
|
||||||
from_id="A6",
|
from_id="A6",
|
||||||
to_type="human",
|
to_type="human",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Agentic API — Spec 029."""
|
"""Agentic API — Spec 029."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -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")
|
@router.get("/roster")
|
||||||
def agents_roster(user=Depends(auth.get_current_user)):
|
def agents_roster(user=Depends(auth.get_current_user)):
|
||||||
_ops_view(user)
|
_ops_view(user)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,16 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
|
||||||
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
|
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
|
||||||
fids.append(fid)
|
fids.append(fid)
|
||||||
if f.get("severity") in ("warn", "high", "critical"):
|
if f.get("severity") in ("warn", "high", "critical"):
|
||||||
|
inc, notify_ops = store.upsert_incident(
|
||||||
|
conn,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
finding_id=fid,
|
||||||
|
title=f.get("title", "Finding"),
|
||||||
|
severity=f.get("severity", "warn"),
|
||||||
|
primary_agent=agent_id,
|
||||||
|
suggested_human_action=human,
|
||||||
|
)
|
||||||
|
if notify_ops:
|
||||||
agent_messages.notify_finding_to_operators(
|
agent_messages.notify_finding_to_operators(
|
||||||
conn,
|
conn,
|
||||||
finding_id=fid,
|
finding_id=fid,
|
||||||
|
|
@ -62,6 +72,7 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
|
||||||
severity=f.get("severity", "warn"),
|
severity=f.get("severity", "warn"),
|
||||||
human_action=human,
|
human_action=human,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
|
thread_id=inc.get("thread_id"),
|
||||||
)
|
)
|
||||||
if f.get("severity") in ("high", "critical"):
|
if f.get("severity") in ("high", "critical"):
|
||||||
notify.notify_finding({**f, "suggested_human_action": human})
|
notify.notify_finding({**f, "suggested_human_action": human})
|
||||||
|
|
@ -69,6 +80,8 @@ def run_scenario(conn, scenario_id, *, trigger="cron"):
|
||||||
status = "ok" if not raw else "degraded"
|
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.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)
|
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()
|
conn.commit()
|
||||||
return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids}
|
return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,28 @@ def init_agent_schema(conn):
|
||||||
CREATE TABLE IF NOT EXISTS agent_kb_chunks (
|
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);
|
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 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):
|
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 (?,?,?,?,?,?)",
|
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]}))
|
if score: scored.append((score, {"source": row["source_path"], "snippet": row["chunk_text"][:400]}))
|
||||||
scored.sort(key=lambda x: -x[0])
|
scored.sort(key=lambda x: -x[0])
|
||||||
return [s[1] for s in scored[:limit]]
|
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
|
||||||
|
|
|
||||||
78
projects/ops-desk/api/tests/test_agents_030.py
Normal file
78
projects/ops-desk/api/tests/test_agents_030.py
Normal file
|
|
@ -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
|
||||||
178
projects/ops-desk/frontend/assets/agentic-ops.css
Normal file
178
projects/ops-desk/frontend/assets/agentic-ops.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,24 @@
|
||||||
(function () {
|
(function () {
|
||||||
const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
let state = { threadId: null, selectedAgent: 'A6' };
|
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 = {}) {
|
async function agentsApi(path, opts = {}) {
|
||||||
const deskApi = typeof globalThis.api === 'function' ? globalThis.api : null;
|
const deskApi = typeof globalThis.api === 'function' ? globalThis.api : null;
|
||||||
if (deskApi) return deskApi(`/v1/agents${path}`, opts);
|
if (deskApi) return deskApi(`/v1/agents${path}`, opts);
|
||||||
|
|
@ -17,163 +33,97 @@
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentCard(a) {
|
function fmtAge(iso) {
|
||||||
const active = state.selectedAgent === a.id ? ' agentic-agent-active' : '';
|
if (!iso) return '—';
|
||||||
return `<article class="card agentic-agent-card${active}" data-agent-id="${esc(a.id)}" tabindex="0">
|
try {
|
||||||
<h4>${esc(a.name)} <span class="pill pill-sm">${esc(a.id)}</span></h4>
|
const ms = Date.now() - new Date(iso).getTime();
|
||||||
<p class="ticket-meta">${esc(a.role)}</p>
|
const m = Math.floor(ms / 60000);
|
||||||
<ul class="agentic-action-list">${(a.actions || []).slice(0, 3).map(x => `<li>${esc(x)}</li>`).join('')}</ul>
|
if (m < 1) return 'agora';
|
||||||
<p class="ticket-meta"><strong>Aprovação:</strong> ${esc(a.approval)}</p>
|
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 `<article class="ao-incident-card${active}" data-incident-id="${inc.id}" data-thread-id="${inc.thread_id || ''}">
|
||||||
|
<div class="ao-incident-title">${esc(inc.title)}</div>
|
||||||
|
<div class="ao-incident-meta">${esc(inc.agent_name)} · ${esc(inc.scenario_id)} · ${fmtAge(inc.last_seen_at)} · ${inc.occurrence_count || 1}×</div>
|
||||||
|
<div class="ao-incident-action">${esc((inc.suggested_human_action || 'Investigar manualmente.').slice(0, 160))}</div>
|
||||||
|
<div class="ao-incident-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-open-incident="${inc.id}">Abrir</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" data-ack-incident="${inc.id}">Ack</button>
|
||||||
|
</div>
|
||||||
</article>`;
|
</article>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inboxRow(m) {
|
function fleetItem(a, openAgents) {
|
||||||
return `<article class="card agentic-inbox-item" data-msg-id="${m.id}" data-thread-id="${m.thread_id}">
|
const active = state.selectedAgent === a.id ? ' ao-fleet-item--active' : '';
|
||||||
<div class="agentic-inbox-head">
|
const pulse = openAgents.has(a.id) ? ' ao-fleet-item--pulse' : '';
|
||||||
<strong>${esc(m.agent_name || m.from_id)}</strong>
|
const dotCls = openAgents.has(a.id) ? ' ao-fleet-dot--active' : '';
|
||||||
<span class="pill">${esc(m.thread_severity || 'info')}</span>
|
const accent = AGENT_ACCENTS[a.id] || '#64748b';
|
||||||
</div>
|
return `<div class="ao-fleet-item${active}${pulse}" data-agent-id="${esc(a.id)}" role="button" tabindex="0">
|
||||||
<p class="ticket-meta">${esc(m.thread_subject || m.message)} · ${esc(m.created_at)}</p>
|
<span class="ao-fleet-dot${dotCls}" style="background:${accent}"></span>
|
||||||
<p class="agentic-inbox-body">${esc((m.body || '').slice(0, 280))}</p>
|
<span>${esc(a.name)}</span>
|
||||||
<div class="agentic-inbox-actions">
|
<span class="pill pill-sm">${esc(a.id)}</span>
|
||||||
<button type="button" class="btn btn-primary btn-sm" data-open-thread="${m.thread_id}">Abrir conversa</button>
|
</div>`;
|
||||||
<button type="button" class="btn btn-ghost btn-sm" data-ack-msg="${m.id}">Arquivar</button>
|
|
||||||
</div>
|
|
||||||
</article>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadBubble(m) {
|
function threadBubble(m) {
|
||||||
const isHuman = m.from_type === 'human';
|
const cls = m.from_type === 'human' ? 'ao-bubble-human' : 'ao-bubble-agent';
|
||||||
const cls = isHuman ? 'agentic-bubble-human' : 'agentic-bubble-agent';
|
return `<div class="ao-bubble ${cls}">
|
||||||
return `<div class="agentic-bubble ${cls}">
|
<div class="ao-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}</div>
|
||||||
<div class="agentic-bubble-meta">${esc(m.from_label || m.from_id)} · ${esc(m.created_at)}</div>
|
<div>${esc(m.body).replace(/\n/g, '<br>')}</div>
|
||||||
<div class="agentic-bubble-body">${esc(m.body).replace(/\n/g, '<br>')}</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadThread(el, threadId) {
|
async function loadThread(el, threadId) {
|
||||||
state.threadId = threadId;
|
state.threadId = threadId;
|
||||||
const box = el.querySelector('#agentic-thread-messages');
|
const box = el.querySelector('#ao-thread-messages');
|
||||||
if (!box) return;
|
if (!box || !threadId) return;
|
||||||
box.innerHTML = '<p class="loading">Carregando thread…</p>';
|
box.innerHTML = '<p class="loading">Carregando…</p>';
|
||||||
const data = await agentsApi(`/threads/${threadId}/messages`);
|
const data = await agentsApi(`/threads/${threadId}/messages`);
|
||||||
box.innerHTML = data.messages.map(threadBubble).join('') || '<p class="empty">Sem mensagens.</p>';
|
box.innerHTML = data.messages.map(threadBubble).join('') || '<p class="empty">Sem mensagens.</p>';
|
||||||
box.scrollTop = box.scrollHeight;
|
box.scrollTop = box.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAgenticOps() {
|
function bindShell(el) {
|
||||||
const el = document.getElementById('agentic-ops-content');
|
el.querySelector('#ao-btn-refresh')?.addEventListener('click', () => renderAgenticOps());
|
||||||
if (!el) return;
|
el.querySelectorAll('[data-agent-id]').forEach((node) => {
|
||||||
el.innerHTML = '<p class="loading">Carregando Agentic Ops…</p>';
|
node.addEventListener('click', () => {
|
||||||
if (!getToken()) {
|
state.selectedAgent = node.dataset.agentId;
|
||||||
el.innerHTML = '<p class="error">Sessão não encontrada neste endereço. <a href="/login.html">Fazer login</a> (use sempre o mesmo URL — ex. desk.ligbox.com.br).</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof ensureValidSession === 'function') {
|
|
||||||
const ok = await ensureValidSession();
|
|
||||||
if (!ok) {
|
|
||||||
el.innerHTML = '<p class="error">Sessão expirada. <a href="/login.html">Fazer login</a></p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const [health, roster, inbox, threads, findings] = await Promise.all([
|
|
||||||
agentsApi('/health'),
|
|
||||||
agentsApi('/roster'),
|
|
||||||
agentsApi('/inbox?limit=20'),
|
|
||||||
agentsApi('/threads?limit=15'),
|
|
||||||
agentsApi('/findings?limit=15'),
|
|
||||||
]);
|
|
||||||
const tier = health.tier === 't1' ? 'T1 LLM' : 'T0';
|
|
||||||
const ollama = health.ollama
|
|
||||||
? `<span class="pill pill-ok">Ollama · ${esc(health.model)}</span>`
|
|
||||||
: '<span class="pill pill-warn">Ollama offline</span>';
|
|
||||||
const agents = roster.agents || [];
|
|
||||||
const inboxItems = inbox.messages || [];
|
|
||||||
const threadOpts = (threads.threads || []).map(t =>
|
|
||||||
`<option value="${t.id}"${state.threadId === t.id ? ' selected' : ''}>#${t.id} ${esc(t.subject)} (${esc(t.agent_name)})</option>`
|
|
||||||
).join('');
|
|
||||||
const fRows = (findings.findings || []).map(f =>
|
|
||||||
`<li><strong>${esc(f.title)}</strong> <span class="pill">${esc(f.severity)}</span>
|
|
||||||
${f.suggested_human_action ? `<br><span class="ticket-meta">${esc(f.suggested_human_action)}</span>` : ''}</li>`
|
|
||||||
).join('') || '<li class="empty">Nenhum finding aberto.</li>';
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<style>
|
|
||||||
.agentic-layout{display:grid;grid-template-columns:minmax(200px,1fr) minmax(260px,1.2fr) minmax(280px,1.4fr);gap:1rem;margin-top:.5rem}
|
|
||||||
@media(max-width:1100px){.agentic-layout{grid-template-columns:1fr}}
|
|
||||||
.agentic-agent-card{cursor:pointer;border:1px solid var(--border,#333);margin-bottom:.5rem;padding:.75rem}
|
|
||||||
.agentic-agent-active{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f680}
|
|
||||||
.agentic-action-list{font-size:.85rem;margin:.4rem 0;padding-left:1.1rem;color:var(--muted,#aaa)}
|
|
||||||
.agentic-inbox-item{margin-bottom:.75rem}
|
|
||||||
.agentic-inbox-body{font-size:.9rem;margin:.35rem 0;white-space:pre-wrap}
|
|
||||||
.agentic-thread-panel{display:flex;flex-direction:column;min-height:420px}
|
|
||||||
.agentic-thread-messages{flex:1;overflow-y:auto;max-height:360px;padding:.5rem;background:rgba(0,0,0,.15);border-radius:6px;margin:.5rem 0}
|
|
||||||
.agentic-bubble{margin:.5rem 0;padding:.6rem .8rem;border-radius:8px;max-width:95%}
|
|
||||||
.agentic-bubble-agent{background:rgba(59,130,246,.12);border-left:3px solid #3b82f6}
|
|
||||||
.agentic-bubble-human{background:rgba(34,197,94,.1);border-left:3px solid #22c55e;margin-left:auto}
|
|
||||||
.agentic-bubble-meta{font-size:.75rem;color:var(--muted,#888);margin-bottom:.25rem}
|
|
||||||
.agentic-chat-box{display:flex;flex-direction:column;gap:.5rem;margin-top:.5rem}
|
|
||||||
.agentic-chat-box textarea{width:100%;min-height:72px}
|
|
||||||
</style>
|
|
||||||
<div class="toolbar agentic-toolbar">
|
|
||||||
<div><h2>Agentic Ops</h2><p class="ticket-meta">Spec 029 · ${tier} ${ollama} · ${inboxItems.length} pendente(s)</p></div>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-refresh">Actualizar</button>
|
|
||||||
</div>
|
|
||||||
<div class="agentic-layout">
|
|
||||||
<section>
|
|
||||||
<h3>Agentes (A0–A7)</h3>
|
|
||||||
<p class="ticket-meta">Clique para seleccionar destino do chat.</p>
|
|
||||||
${agents.map(agentCard).join('')}
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<h3>Inbox operadores</h3>
|
|
||||||
<p class="ticket-meta">Mensagens dos agentes que exigem acção humana.</p>
|
|
||||||
${inboxItems.length ? inboxItems.map(inboxRow).join('') : '<p class="empty">Inbox vazia.</p>'}
|
|
||||||
<h3 style="margin-top:1rem">Findings abertos</h3>
|
|
||||||
<ul class="agentic-findings-list">${fRows}</ul>
|
|
||||||
</section>
|
|
||||||
<section class="card agentic-thread-panel">
|
|
||||||
<h3>Janela de contexto</h3>
|
|
||||||
<label class="ticket-meta">Thread
|
|
||||||
<select id="agentic-thread-select" class="input">${threadOpts || '<option value="">—</option>'}</select>
|
|
||||||
</label>
|
|
||||||
<div id="agentic-thread-messages" class="agentic-thread-messages"><p class="empty">Seleccione uma thread ou abra da inbox.</p></div>
|
|
||||||
<textarea id="agentic-reply-input" rows="2" class="input" placeholder="Responder ao agente…"></textarea>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm" id="btn-agentic-reply">Enviar resposta</button>
|
|
||||||
<hr style="margin:1rem 0;opacity:.3">
|
|
||||||
<h4>Chat Copiloto (${esc(state.selectedAgent)})</h4>
|
|
||||||
<div class="agentic-chat-box">
|
|
||||||
<textarea id="agentic-chat-input" rows="2" placeholder="Pergunta ao agente seleccionado…"></textarea>
|
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="btn-agentic-chat">Perguntar</button>
|
|
||||||
</div>
|
|
||||||
<div id="agentic-chat-answer" class="agentic-chat-answer" hidden></div>
|
|
||||||
</section>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
el.querySelector('#btn-agentic-refresh')?.addEventListener('click', renderAgenticOps);
|
|
||||||
el.querySelectorAll('.agentic-agent-card').forEach(card => {
|
|
||||||
card.addEventListener('click', () => {
|
|
||||||
state.selectedAgent = card.dataset.agentId;
|
|
||||||
renderAgenticOps();
|
renderAgenticOps();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
el.querySelectorAll('[data-open-thread]').forEach(btn => {
|
el.querySelectorAll('[data-incident-id], [data-open-incident]').forEach((node) => {
|
||||||
btn.addEventListener('click', () => loadThread(el, parseInt(btn.dataset.openThread, 10)));
|
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-msg]').forEach(btn => {
|
});
|
||||||
btn.addEventListener('click', async () => {
|
el.querySelectorAll('[data-ack-incident]').forEach((btn) => {
|
||||||
await agentsApi(`/messages/${btn.dataset.ackMsg}/ack`, { method: 'POST' });
|
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();
|
await renderAgenticOps();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
el.querySelector('#agentic-thread-select')?.addEventListener('change', (e) => {
|
el.querySelector('#ao-btn-reply')?.addEventListener('click', async () => {
|
||||||
const id = parseInt(e.target.value, 10);
|
const input = el.querySelector('#ao-reply-input');
|
||||||
if (id) loadThread(el, id);
|
const tid = state.threadId;
|
||||||
});
|
|
||||||
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();
|
const body = (input?.value || '').trim();
|
||||||
if (!tid || !body) return;
|
if (!tid || !body) return;
|
||||||
await agentsApi(`/threads/${tid}/reply`, {
|
await agentsApi(`/threads/${tid}/reply`, {
|
||||||
|
|
@ -183,9 +133,9 @@
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await loadThread(el, tid);
|
await loadThread(el, tid);
|
||||||
});
|
});
|
||||||
el.querySelector('#btn-agentic-chat')?.addEventListener('click', async () => {
|
el.querySelector('#ao-btn-chat')?.addEventListener('click', async () => {
|
||||||
const input = el.querySelector('#agentic-chat-input');
|
const input = el.querySelector('#ao-chat-input');
|
||||||
const out = el.querySelector('#agentic-chat-answer');
|
const out = el.querySelector('#ao-chat-answer');
|
||||||
const q = (input?.value || '').trim();
|
const q = (input?.value || '').trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
out.hidden = false;
|
out.hidden = false;
|
||||||
|
|
@ -204,10 +154,141 @@
|
||||||
out.innerHTML = `<p class="error">${esc(err.message)}</p>`;
|
out.innerHTML = `<p class="error">${esc(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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;
|
||||||
|
if (!options.poll) {
|
||||||
|
el.innerHTML = '<div class="ao-status-bar"><p class="loading">Carregando Mission Board…</p></div>'
|
||||||
|
+ '<div class="ao-board"><div class="ao-skeleton"></div><div class="ao-skeleton"></div></div>';
|
||||||
|
}
|
||||||
|
if (!getToken()) {
|
||||||
|
el.innerHTML = '<p class="error">Sessão não encontrada. <a href="/login.html">Fazer login</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!options.poll && typeof ensureValidSession === 'function') {
|
||||||
|
const ok = await ensureValidSession();
|
||||||
|
if (!ok) {
|
||||||
|
el.innerHTML = '<p class="error">Sessão expirada. <a href="/login.html">Fazer login</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const [overview, incidents, roster, timeline] = await Promise.all([
|
||||||
|
agentsApi('/overview'),
|
||||||
|
agentsApi('/incidents?status=open&limit=50'),
|
||||||
|
agentsApi('/roster'),
|
||||||
|
agentsApi('/timeline?limit=12'),
|
||||||
|
]);
|
||||||
|
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
|
||||||
|
? `<span class="pill pill-ok">Ollama · ${esc(overview.model)}</span>`
|
||||||
|
: '<span class="pill pill-warn">Ollama offline</span>';
|
||||||
|
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('')
|
||||||
|
|| '<p class="ticket-meta" style="font-size:0.75rem">—</p>';
|
||||||
|
return `<div class="ao-board-col ${col.cls}"><h4>${col.label}</h4>${cards}</div>`;
|
||||||
|
}).join('');
|
||||||
|
const fleet = (roster.agents || []).map((a) => fleetItem(a, openAgents)).join('');
|
||||||
|
const ticks = (timeline.ticks || []).map((t) =>
|
||||||
|
`<div class="ao-timeline-row"><span>${esc(fmtAge(t.at))}</span><span>${t.scenarios || '—'} cenários · ${t.findings || 0} findings</span></div>`
|
||||||
|
).join('') || '<p class="ticket-meta">Sem ticks recentes.</p>';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="ao-status-bar">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin:0;font-size:1.1rem">Agentic Ops</h2>
|
||||||
|
<div class="ao-status-metrics">
|
||||||
|
<span class="pill">${tier}</span> ${ollamaPill}
|
||||||
|
<span>Último tick: ${fmtAge(overview.last_tick_at)}</span>
|
||||||
|
<span>${overview.scenarios_ok || 0}/${overview.scenarios_total || 9} cenários OK</span>
|
||||||
|
<span>Abertos: ${open.high || 0} alto · ${open.warn || 0} aviso</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="ao-btn-refresh">Actualizar (R)</button>
|
||||||
|
</div>
|
||||||
|
<div class="ao-mobile-tabs">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'board' ? ' active' : ''}" data-ao-tab="board">Board</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'fleet' ? ' active' : ''}" data-ao-tab="fleet">Frota</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm${state.mobileTab === 'context' ? ' active' : ''}" data-ao-tab="context">Contexto</button>
|
||||||
|
</div>
|
||||||
|
<div class="ao-shell">
|
||||||
|
<aside class="ao-fleet-rail ao-pane${state.mobileTab === 'fleet' ? ' ao-pane--active' : ''}" data-ao-pane="fleet">
|
||||||
|
<h3>Frota A0–A7</h3>
|
||||||
|
<div class="ao-fleet-item${state.selectedAgent === 'ALL' ? ' ao-fleet-item--active' : ''}" data-agent-id="ALL" role="button"><span class="ao-fleet-dot"></span><span>Todos</span></div>
|
||||||
|
${fleet}
|
||||||
|
</aside>
|
||||||
|
<section class="ao-pane${state.mobileTab === 'board' ? ' ao-pane--active' : ''}" data-ao-pane="board">
|
||||||
|
<div class="ao-board">${filtered.length ? boardCols : '<div class="ao-empty"><p>✓ Ambiente saudável — nenhum incidente aberto.</p></div>'}</div>
|
||||||
|
<div class="ao-timeline"><h4 style="margin:0 0 0.5rem;font-size:0.8rem">Timeline ticks (24h)</h4>${ticks}</div>
|
||||||
|
</section>
|
||||||
|
<aside class="card ao-context-panel ao-pane${state.mobileTab === 'context' ? ' ao-pane--active' : ''}" data-ao-pane="context">
|
||||||
|
<h3>Janela de contexto</h3>
|
||||||
|
${sel ? `<p class="ticket-meta"><strong>${esc(sel.title)}</strong><br>${esc(sel.scenario_id)} · ${esc(sel.agent_name)}</p>` : '<p class="ticket-meta">Seleccione um incidente no board.</p>'}
|
||||||
|
<div id="ao-thread-messages" class="ao-thread-messages">${state.threadId ? '<p class="loading">…</p>' : '<p class="empty">Nenhuma thread seleccionada.</p>'}</div>
|
||||||
|
<textarea id="ao-reply-input" rows="2" class="input" placeholder="Responder ao agente…"></textarea>
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" id="ao-btn-reply">Enviar resposta</button>
|
||||||
|
<hr style="margin:0.75rem 0;opacity:0.25">
|
||||||
|
<h4>Copiloto (${esc(state.selectedAgent)})</h4>
|
||||||
|
<textarea id="ao-chat-input" rows="2" class="input" placeholder="Pergunta ao agente…"></textarea>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="ao-btn-chat">Perguntar</button>
|
||||||
|
<div id="ao-chat-answer" class="ticket-meta" hidden></div>
|
||||||
|
</aside>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
bindShell(el);
|
||||||
if (state.threadId) await loadThread(el, state.threadId);
|
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) {
|
} catch (err) {
|
||||||
el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
|
if (!options.poll) el.innerHTML = `<p class="error">Erro: ${esc(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
window.renderAgenticOps = renderAgenticOps;
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
<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"/>
|
||||||
</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">
|
||||||
|
|
@ -443,7 +444,7 @@
|
||||||
<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=20260620agentic3"></script>
|
<script src="/assets/agentic-ops.js?v=20260620v2"></script>
|
||||||
<script src="/assets/app.js?v=20260620agentic3"></script>
|
<script src="/assets/app.js?v=20260620v2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
143
specs/030-agentic-ops-ui/contracts/agentic-ui-api.md
Normal file
143
specs/030-agentic-ops-ui/contracts/agentic-ui-api.md
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
# Contract — Agentic UI API v1.1 (Spec 030)
|
||||||
|
|
||||||
|
Extensão da API Spec 029. Base: `/api/v1/agents`.
|
||||||
|
|
||||||
|
**Auth:** igual 029 — JWT ops view (`get_current_user` + `_ops_view`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET `/overview`
|
||||||
|
|
||||||
|
Resumo para status bar.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier": "t1",
|
||||||
|
"ollama": true,
|
||||||
|
"model": "qwen2.5:7b-instruct",
|
||||||
|
"last_tick_at": "2026-06-20T06:32:02.723068+00:00",
|
||||||
|
"last_tick_status": "degraded",
|
||||||
|
"scenarios_total": 9,
|
||||||
|
"scenarios_ok": 5,
|
||||||
|
"incidents_open": {
|
||||||
|
"critical": 0,
|
||||||
|
"high": 2,
|
||||||
|
"warn": 2,
|
||||||
|
"info": 0
|
||||||
|
},
|
||||||
|
"worker_interval_sec": 600
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET `/incidents`
|
||||||
|
|
||||||
|
Lista deduplicada para mission board.
|
||||||
|
|
||||||
|
**Query:**
|
||||||
|
|
||||||
|
| Param | Tipo | Default |
|
||||||
|
|-------|------|---------|
|
||||||
|
| `status` | `open` \| `ack` \| `all` | `open` |
|
||||||
|
| `severity` | string | — |
|
||||||
|
| `agent_id` | string | — |
|
||||||
|
| `limit` | int | 50 |
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"incidents": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"scenario_id": "vm123.finance.stack",
|
||||||
|
"title": "FOSSBilling VM123 down",
|
||||||
|
"severity": "high",
|
||||||
|
"status": "open",
|
||||||
|
"primary_agent": "sentinel",
|
||||||
|
"agent_name": "Vigia",
|
||||||
|
"occurrence_count": 12,
|
||||||
|
"first_seen_at": "2026-06-19T23:45:16Z",
|
||||||
|
"last_seen_at": "2026-06-20T06:32:02Z",
|
||||||
|
"suggested_human_action": "Verificar docker compose VM123 finance stack",
|
||||||
|
"thread_id": 47,
|
||||||
|
"latest_finding_id": 253
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET `/incidents/{id}`
|
||||||
|
|
||||||
|
Detalhe + runs recentes do cenário.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"incident": { "...": "..." },
|
||||||
|
"recent_runs": [
|
||||||
|
{
|
||||||
|
"run_id": 758,
|
||||||
|
"status": "degraded",
|
||||||
|
"started_at": "2026-06-20T06:25:34Z",
|
||||||
|
"findings_count": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thread_id": 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST `/incidents/{id}/ack`
|
||||||
|
|
||||||
|
Ack incidente, mensagens inbox associadas, e finding latest.
|
||||||
|
|
||||||
|
**Response 200:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"incident_id": 1,
|
||||||
|
"thread_id": 47,
|
||||||
|
"findings_acked": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**RBAC:** `super_admin`, `ops_lead`, `technician`, `agentic_operator`, `devops`, `security_analyst` (mesmo `_ops_view`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alteração no tick (runner)
|
||||||
|
|
||||||
|
Pseudo-lógica em `notify_finding_to_operators`:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Upsert agent_incidents ON scenario_id
|
||||||
|
- increment occurrence_count
|
||||||
|
- update last_seen_at, latest_finding_id, severity (max)
|
||||||
|
2. Reutilizar thread_id do incidente se open
|
||||||
|
3. Post message apenas se severity escalou OU first_seen OR human_action mudou
|
||||||
|
```
|
||||||
|
|
||||||
|
Evita 140 threads para 4 cenários.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend consumption
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// agentic-ops v2 boot
|
||||||
|
const [overview, incidents] = await Promise.all([
|
||||||
|
agentsApi('/overview'),
|
||||||
|
agentsApi('/incidents?status=open'),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback UI-A (sem backend): agrupar `/findings?open_only=true` por `scenario_id` no JS até UI-B deploy.
|
||||||
194
specs/030-agentic-ops-ui/design/wireframes.md
Normal file
194
specs/030-agentic-ops-ui/design/wireframes.md
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
# Wireframes — Spec 030 Agentic Ops UI
|
||||||
|
|
||||||
|
**Data:** 2026-06-20
|
||||||
|
**View Desk:** `agentic-ops` · `#agentic-ops-content`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Status bar (fixo no topo da view)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Agentic Ops Tier [T1] Ollama ● qwen2.5:7b │
|
||||||
|
│ Último tick: há 4 min · Worker OK │
|
||||||
|
│ Abertos: 2 alto · 2 aviso · 5 ok │
|
||||||
|
│ [Actualizar] [Tick]│
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dados:** `GET /api/v1/agents/overview` (novo) ou compor de `/health` + último run em `/action-log`.
|
||||||
|
|
||||||
|
**Estados Ollama:**
|
||||||
|
- `● verde` — tier t1 + ollama true
|
||||||
|
- `● amarelo` — tier t1 + ollama false (fallback T0)
|
||||||
|
- `T0` badge — LLM desligado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fleet rail (coluna esquerda, ~220px)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────┐
|
||||||
|
│ FROTA A0–A7 │
|
||||||
|
├──────────────────┤
|
||||||
|
│ ● Maestro A0 │ ← pulse se orchestrator activo
|
||||||
|
│ ○ Pulso A1 │
|
||||||
|
│ ○ Trilho A2 │
|
||||||
|
│ ○ Carta A3 │ (futuro — disabled/grey)
|
||||||
|
│ ○ Escudo A4 │
|
||||||
|
│ ○ Sentinela A5 │
|
||||||
|
│ ● Copiloto A6 │ ← seleccionado para chat
|
||||||
|
│ ○ Remediador A7 │
|
||||||
|
├──────────────────┤
|
||||||
|
│ Vigia · Curador │
|
||||||
|
├──────────────────┤
|
||||||
|
│ Filtrar: [Todos ▼]│
|
||||||
|
└──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interacção:** click agente filtra cards do board; double-click abre chat A6 pré-preenchido.
|
||||||
|
|
||||||
|
**Indicador pulse:** agente com ≥1 incidente `open` no cenário mapeado (Spec 029 `SCENARIO_AGENT_MAP`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mission Board — kanban por severidade
|
||||||
|
|
||||||
|
Colunas (horizontal scroll se necessário):
|
||||||
|
|
||||||
|
| Coluna | Cor borda | Max cards visíveis |
|
||||||
|
|--------|-----------|-------------------|
|
||||||
|
| CRÍTICO | `#ef4444` | todos |
|
||||||
|
| ALTO | `#f97316` | todos |
|
||||||
|
| AVISO | `#eab308` | 8 + “+N” |
|
||||||
|
| OK / Silencioso | `#64748b` | collapsible |
|
||||||
|
|
||||||
|
### Incident card (template)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ [ALTO] FOSSBilling VM123 down │
|
||||||
|
│ Vigia · vm123.finance.stack │
|
||||||
|
│ Detectado há 6 min · 12 ocorrências │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Acção: Verificar docker compose na │
|
||||||
|
│ VM123 finance stack… │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [Abrir] [Ack] [⋯] │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Card activo:** borda `2px solid var(--accent)` + fundo `rgba(59,130,246,0.08)`.
|
||||||
|
|
||||||
|
**Menu ⋯:** Copiar acção · Ver runs · Criar ticket (futuro UI-E).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Context panel (coluna direita, ~360px)
|
||||||
|
|
||||||
|
Estados:
|
||||||
|
|
||||||
|
### A — Nenhum card seleccionado
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ CONTEXTO │
|
||||||
|
│ Seleccione um incidente │
|
||||||
|
│ no board ou use o │
|
||||||
|
│ Copiloto abaixo. │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ Chat Copiloto (A6) │
|
||||||
|
│ [textarea] │
|
||||||
|
│ [Perguntar] │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### B — Card seleccionado
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ FOSSBilling VM123 down │
|
||||||
|
│ Thread #47 · Vigia │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ ▼ Timeline (3 eventos) │
|
||||||
|
│ 06:32 tick · degraded │
|
||||||
|
│ 06:22 tick · degraded │
|
||||||
|
│ 06:12 finding criado │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ [mensagens thread…] │
|
||||||
|
│ [responder…] [Enviar] │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ Copiloto A6 │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Run timeline (faixa inferior, opcional collapsible)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Últimas 24h — ticks worker │
|
||||||
|
│ 06:32 ████ 9/9 cenários · 4 findings │
|
||||||
|
│ 06:22 ████ · 4 findings │
|
||||||
|
│ 06:12 ████ · 4 findings │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementação v1:** lista simples; v2 sparkline SVG (como wizard SOC em `app.js`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Mobile (<768px)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Status bar (compact) │
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ [Board][Frota][Contexto]│ ← tabs
|
||||||
|
├─────────────────────────┤
|
||||||
|
│ (tab activa) │
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Empty / loading / error
|
||||||
|
|
||||||
|
| Estado | UI |
|
||||||
|
|--------|-----|
|
||||||
|
| Loading | Skeleton 3 colunas + “Carregando mission board…” |
|
||||||
|
| Empty | Ilustração minimal + “Ambiente saudável — 9/9 cenários OK” |
|
||||||
|
| Error | Banner vermelho + botão retry (não substituir página inteira) |
|
||||||
|
| 401 | Redirect login (já via `api()` global) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS — classes propostas (prefixo `ao-`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ao-shell { display: grid; grid-template-columns: 220px 1fr 360px; gap: 1rem; }
|
||||||
|
.ao-status-bar { … }
|
||||||
|
.ao-fleet-rail { … }
|
||||||
|
.ao-board { display: grid; grid-template-columns: repeat(4, minmax(200px, 1fr)); gap: .75rem; }
|
||||||
|
.ao-incident-card { min-height: 140px; cursor: pointer; }
|
||||||
|
.ao-incident-card--active { … }
|
||||||
|
.ao-severity--critical { border-left: 4px solid #ef4444; }
|
||||||
|
.ao-context-panel { display: flex; flex-direction: column; min-height: 480px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Integrar em `styles.css` ou ficheiro `agentic-ops.css` importado só na view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migração desde layout 029 (3 colunas)
|
||||||
|
|
||||||
|
| 029 | 030 |
|
||||||
|
|-----|-----|
|
||||||
|
| Coluna roster expandido | Fleet rail compacto |
|
||||||
|
| Inbox lista longa | Cards no kanban |
|
||||||
|
| Findings lista duplicada | Merge no card |
|
||||||
|
| Dropdown threads | Context panel ao seleccionar |
|
||||||
|
| Chat no fundo | Chat fixo no context panel |
|
||||||
|
|
||||||
|
**Rollback:** feature flag `AGENTIC_UI_V2=true` em `.env` ou toggle módulo Desk (Spec 015).
|
||||||
198
specs/030-agentic-ops-ui/spec.md
Normal file
198
specs/030-agentic-ops-ui/spec.md
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
# Spec 030 — Agentic Ops UI (Mission Board)
|
||||||
|
|
||||||
|
**Criado:** 2026-06-20
|
||||||
|
**Solicitado por:** Roger
|
||||||
|
**Prioridade:** P1 (UX operacional — pós Spec 029 MVP)
|
||||||
|
**Status:** ✅ Implementado (UI-A/B/C) — produção VM122
|
||||||
|
**Sistema:** Desk VM122 · view `agentic-ops`
|
||||||
|
**Relacionado:** Spec **029** (API + agentes) · Spec **033** (proc-card / modal) · Spec **019** (hub CH-* — futuro link)
|
||||||
|
|
||||||
|
**Referências externas (padrões UX, não fork):**
|
||||||
|
- [Mission Control](https://github.com/builderz-labs/mission-control) — mission board, inbox, timeline, SSE
|
||||||
|
- [Agent Track Dashboard](https://github.com/jenna-studio/agent-track-dashboard) — kanban por agente/tarefa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema actual (029 MVP)
|
||||||
|
|
||||||
|
| Sintoma | Causa |
|
||||||
|
|---------|--------|
|
||||||
|
| 140+ threads repetidas | Cada tick cron cria finding + thread novo (mesmo cenário) |
|
||||||
|
| 3 colunas iguais visualmente | Roster, inbox e findings competem sem hierarquia |
|
||||||
|
| Dropdown de threads inútil | Lista longa, sem agrupamento |
|
||||||
|
| Operador perde foco | Não há “o que fazer agora” num só sítio |
|
||||||
|
|
||||||
|
**Objectivo:** transformar Agentic Ops num **painel de comando** (mission board), não num dump de mensagens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Princípios de design
|
||||||
|
|
||||||
|
1. **Um problema = um card** — deduplicação por `scenario_id` (não por `finding_id`)
|
||||||
|
2. **Severidade manda** — layout lido de cima: Crítico → Alto → Aviso → OK
|
||||||
|
3. **Agente visível** — cor/avatar por A0–A7 + Vigia (Spec 029 roster)
|
||||||
|
4. **Acção humana clara** — cada card: título, última detecção, passo sugerido (T0/T1), botões Ack / Abrir thread / Atribuir
|
||||||
|
5. **Contexto à direita** — thread + chat Copiloto só quando o operador selecciona um card
|
||||||
|
6. **Status bar sempre visível** — tier T0/T1, Ollama, último tick, contagem aberta
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wireframe — desktop (≥1200px)
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ AGENTIC OPS [T1 · Ollama OK] último tick 06:32 │ 12 abertos │ ↻ │
|
||||||
|
├────────────┬─────────────────────────────────────────────────┬───────────────┤
|
||||||
|
│ FROTA │ MISSION BOARD (kanban horizontal) │ CONTEXTO │
|
||||||
|
│ │ │ │
|
||||||
|
│ ● Maestro │ ┌─ CRÍTICO ─┐ ┌─ ALTO ────┐ ┌─ AVISO ──┐ ┌ OK ┐│ Thread #47 │
|
||||||
|
│ ○ Pulso │ │ (vazio) │ │ FOSSBill │ │ OpenPanel│ │ 5 ││ FOSS VM123 │
|
||||||
|
│ ○ Trilho │ │ │ │ VM123 │ │ bridge │ │ ││ │
|
||||||
|
│ ○ Copiloto │ │ │ │ gap 415m │ │ funil 10 │ │ ││ [timeline] │
|
||||||
|
│ … │ └───────────┘ └───────────┘ └──────────┘ └────┘│ Vigia → humano│
|
||||||
|
│ │ ↑ card activo (borda) │ │
|
||||||
|
│ [filtro │ Card: cenário · agente · há 8 min · ack │ [responder] │
|
||||||
|
│ agente] │ │ │
|
||||||
|
│ │ Timeline compacta (últimas 24h, collapsible) │ Copiloto A6 │
|
||||||
|
│ │ ████░░ ticks · 4 findings únicos │ [perguntar…] │
|
||||||
|
└────────────┴─────────────────────────────────────────────────┴───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile / tablet (<1200px)
|
||||||
|
|
||||||
|
- Tab bar: **Board** | **Frota** | **Contexto**
|
||||||
|
- Kanban vira lista vertical por severidade (accordion)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes UI
|
||||||
|
|
||||||
|
| Componente | Descrição | Ficheiro alvo (v1) |
|
||||||
|
|------------|-----------|-------------------|
|
||||||
|
| `AgenticStatusBar` | tier, ollama, last_tick, counts | `agentic-ops.js` ou `agentic-ops/` |
|
||||||
|
| `AgentFleetRail` | A0–A7 compacto + pulse se finding aberto | idem |
|
||||||
|
| `MissionBoard` | 4 colunas severidade, cards deduplicados | idem |
|
||||||
|
| `IncidentCard` | cenário, agente, age, action, CTA | idem |
|
||||||
|
| `RunTimeline` | últimos N ticks / runs (sparkline ou lista) | idem |
|
||||||
|
| `ContextPanel` | thread messages + reply + chat A6 | idem |
|
||||||
|
| `EmptyState` | “Nenhum alerta — último tick OK” | idem |
|
||||||
|
|
||||||
|
**Tokens visuais:** reutilizar `styles.css` Desk (`.card`, `.pill`, `.badge`, cores SOC existentes).
|
||||||
|
|
||||||
|
**Cores por agente (proposta):**
|
||||||
|
|
||||||
|
| ID | Accent | Uso |
|
||||||
|
|----|--------|-----|
|
||||||
|
| A0 Maestro | `#6366f1` | coordenação |
|
||||||
|
| A1 Pulso | `#22c55e` | nós/VM112 |
|
||||||
|
| A2 Trilho | `#3b82f6` | rede/DNS |
|
||||||
|
| A6 Copiloto | `#a855f7` | chat |
|
||||||
|
| Vigia | `#f59e0b` | findings T0 |
|
||||||
|
| A7 Remediador | `#ef4444` | acções (futuro) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modelo de dados — deduplicação (backend)
|
||||||
|
|
||||||
|
### Regra
|
||||||
|
|
||||||
|
- **Incidente activo** = 1 por `(scenario_id)` enquanto existir finding `open` (não ack)
|
||||||
|
- Novos ticks **actualizam** o incidente (last_seen, count, latest_finding_id) em vez de criar thread nova
|
||||||
|
- Thread **reutilizada** via `related_scenario_id` (nova coluna) ou lookup por cenário
|
||||||
|
|
||||||
|
### Tabela nova (proposta)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE 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', -- open | ack | resolved
|
||||||
|
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
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### API nova (v1.1 agents)
|
||||||
|
|
||||||
|
| Método | Path | Descrição |
|
||||||
|
|--------|------|-----------|
|
||||||
|
| GET | `/api/v1/agents/incidents` | Lista deduplicada para kanban (`?status=open`) |
|
||||||
|
| GET | `/api/v1/agents/incidents/{id}` | Detalhe + timeline runs recentes |
|
||||||
|
| POST | `/api/v1/agents/incidents/{id}/ack` | Ack incidente + fecha thread inbox |
|
||||||
|
| GET | `/api/v1/agents/overview` | Status bar: last_tick, counts by severity, ollama |
|
||||||
|
|
||||||
|
**Compatibilidade:** endpoints 029 (`/inbox`, `/findings`, `/threads`) mantidos; UI 030 consome preferencialmente `/incidents` + `/overview`.
|
||||||
|
|
||||||
|
Ver [`contracts/agentic-ui-api.md`](contracts/agentic-ui-api.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxos operador
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Login Desk] --> B[Agentic Ops]
|
||||||
|
B --> C{Overview}
|
||||||
|
C --> D[Mission Board]
|
||||||
|
D --> E[Seleccionar card]
|
||||||
|
E --> F[Context Panel: thread]
|
||||||
|
F --> G{Acção}
|
||||||
|
G --> H[Ack / Arquivar]
|
||||||
|
G --> I[Reply humano]
|
||||||
|
G --> J[Chat A6 T1]
|
||||||
|
H --> D
|
||||||
|
I --> F
|
||||||
|
J --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fases de entrega
|
||||||
|
|
||||||
|
| Fase | Entrega | Depende |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **UI-A** | Status bar + Mission Board (dados actuais `/findings` agrupados no frontend) | — |
|
||||||
|
| **UI-B** | Backend `agent_incidents` + dedup no tick | UI-A |
|
||||||
|
| **UI-C** | Fleet rail + timeline + mobile tabs | UI-A |
|
||||||
|
| **UI-D** | SSE/poll 30s (live refresh) | Spec 029 H3 |
|
||||||
|
| **UI-E** | Link card → ticket Desk / CH-* hub | Spec 019 |
|
||||||
|
|
||||||
|
Detalhe: [`tasks.md`](tasks.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critérios de aceitação
|
||||||
|
|
||||||
|
- [ ] Operador vê **≤10 cards** para os 4 cenários recurrentes actuais (não 140 threads)
|
||||||
|
- [ ] Card mostra: severidade, agente, cenário, “há X min”, acção sugerida
|
||||||
|
- [ ] Click card abre contexto com thread **única** por cenário
|
||||||
|
- [ ] Ack remove card da coluna activa
|
||||||
|
- [ ] Status bar reflecte último tick worker (<15 min em produção)
|
||||||
|
- [ ] Layout responsivo (3 tabs em mobile)
|
||||||
|
- [ ] Zero regressão auth JWT / RBAC Spec 027
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fora de scope (030)
|
||||||
|
|
||||||
|
- Substituir API 029 por Mission Control externo
|
||||||
|
- WebSocket full-duplex (fica UI-D / Spec 029 H3)
|
||||||
|
- Runbooks R0–R3 automáticos (Spec 029 H2)
|
||||||
|
- React rewrite completo do Desk (opcional fase futura `agentic-ops/` Vite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referências internas
|
||||||
|
|
||||||
|
- [`design/wireframes.md`](design/wireframes.md) — detalhe visual e estados
|
||||||
|
- [`contracts/agentic-ui-api.md`](contracts/agentic-ui-api.md) — contrato API v1.1
|
||||||
|
- Spec 029 [`agents-roster.md`](../029-agentic-ops-runbooks/agents-roster.md)
|
||||||
57
specs/030-agentic-ops-ui/tasks.md
Normal file
57
specs/030-agentic-ops-ui/tasks.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Tasks — Spec 030 Agentic Ops UI
|
||||||
|
|
||||||
|
## Fase UI-A — Mission Board (frontend only) ✅
|
||||||
|
- [x] A1 Criar `agentic-ops.css` (grid `ao-*`, tokens agente)
|
||||||
|
- [x] A2 Componente `AgenticStatusBar` (health + action-log last tick)
|
||||||
|
- [x] A3 Componente `MissionBoard` — 4 colunas severidade
|
||||||
|
- [x] A4 Dedup via API `/incidents` (UI-B)
|
||||||
|
- [x] A5 `IncidentCard` — click selecciona + highlight
|
||||||
|
- [x] A6 `ContextPanel` — thread + reply + chat
|
||||||
|
- [x] A7 Fleet rail compacto
|
||||||
|
- [x] A8 Deploy produção + staging
|
||||||
|
|
||||||
|
## Fase UI-B — Backend deduplicação ✅
|
||||||
|
- [x] B1 Migration `agent_incidents` + índices
|
||||||
|
- [x] B2 `store.upsert_incident()` no runner tick
|
||||||
|
- [x] B3 Refactor `notify_finding_to_operators` — reutilizar thread
|
||||||
|
- [x] B4 Endpoints `/overview`, `/incidents`, `/incidents/{id}/ack`, `/timeline`
|
||||||
|
- [x] B5 Backfill automático no boot (`backfill_incidents`)
|
||||||
|
- [x] B6 Testes `test_agents_030.py`
|
||||||
|
|
||||||
|
## Fase UI-C — Polish ✅
|
||||||
|
- [x] C1 Run timeline (lista ticks)
|
||||||
|
- [x] C2 Mobile tabs (Board / Frota / Contexto)
|
||||||
|
- [x] C3 Empty states + skeleton loading
|
||||||
|
- [x] C4 Pulse animation agente com incidente open
|
||||||
|
- [x] C5 Atalho teclado `R` refresh, `Esc` deselect
|
||||||
|
- [x] C6 Poll 30s quando view activa
|
||||||
|
|
||||||
|
## Fase UI-D — Live updates 📋
|
||||||
|
|
||||||
|
- [ ] D1 Poll 30s quando view activa (pausa tab hidden)
|
||||||
|
- [ ] D2 SSE `/api/v1/agents/stream` (opcional, Spec 029 H3)
|
||||||
|
|
||||||
|
## Fase UI-E — Integração Desk 📋
|
||||||
|
|
||||||
|
- [ ] E1 Botão “Criar ticket” no card → `/api/v1/tickets` POST
|
||||||
|
- [ ] E2 Link finding → domínio/tenant se aplicável
|
||||||
|
- [ ] E3 Preparar hub CH-* (Spec 019) — deep link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordem recomendada Roger
|
||||||
|
|
||||||
|
1. **UI-A** (1–2 dias) — impacto visual imediato, zero risco DB
|
||||||
|
2. **UI-B** (1 dia) — limpa produção, para spam de threads
|
||||||
|
3. **UI-C** (0.5 dia) — mobile + polish
|
||||||
|
4. UI-D / UI-E — backlog P2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Definition of Done (030 MVP = UI-A + UI-B)
|
||||||
|
|
||||||
|
- [ ] ≤10 cards visíveis para ambiente actual
|
||||||
|
- [ ] Ack funcional end-to-end
|
||||||
|
- [ ] Context panel com thread única por cenário
|
||||||
|
- [ ] Documentação quickstart actualizada
|
||||||
|
- [ ] Deploy produção `:8091` após staging OK
|
||||||
Loading…
Reference in a new issue