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:
Ligbox Spec Hub 2026-06-20 06:49:38 +00:00
parent 1e8deb23ec
commit fd491e5859
13 changed files with 1450 additions and 178 deletions

View file

@ -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 |
--- ---

View file

@ -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",

View file

@ -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)

View file

@ -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}

View file

@ -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

View 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

View 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; }
}

View file

@ -1,8 +1,24 @@
(function () { (function () {
const esc = (s) => String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 `${m} min`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${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 (A0A7)</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 A0A7</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;
})(); })();

View file

@ -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>

View 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.

View 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 A0A7 │
├──────────────────┤
│ ● 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).

View 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 A0A7 + 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` | A0A7 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 R0R3 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)

View 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** (12 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