feat(frontend): Monitoramento em tempo real (WebSocket via proxy SSE)

O RealtimeGateway (backend, desde a PHASE 13) autentica a conexão
socket.io via auth.token no handshake — um JWT bruto que um
EventSource/WebSocket do browser não tem como mandar sem passar por JS
legível no client, quebrando o princípio seguido em todo o resto do
frontend (token só existe no cookie httpOnly). Resolvido com um proxy:
apps/frontend/src/app/api/monitoring/stream/route.ts roda no servidor,
conecta no socket.io real com o access token do lado do servidor, e
reencaminha cada evento pro browser como Server-Sent Events — o
EventSource do client só precisa do cookie de sessão, nunca do token.

/app/monitoramento: badge de conexão, contadores de sessão (chamadas
criadas/atendidas/encerradas), filas ao vivo (QUEUE_MEMBER_COUNT), estado
de agentes ao vivo (AGENT_STATE_CHANGED, semeado do GET /agents inicial),
feed dos últimos 50 eventos. Menu "Monitoramento" vira 1 link direto em
vez de 5 sub-itens placeholder — o painel novo já cobre tudo numa página
só.

Testado ponta a ponta contra o pipeline real (não simulado): cliente
socket.io cru confirmou a API entregando o evento, curl -N confirmou o
proxy reencaminhando, e com a página aberta de verdade num browser
(Puppeteer) disparei POST /agents/me/login e /logout por fora — o badge
do agente mudou ao vivo e os eventos apareceram no feed sem recarregar a
página. Smoke test de regressão nas 19 telas anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 19:17:39 -03:00
parent 7f56c96454
commit 3479dbd007
11 changed files with 481 additions and 16 deletions

View File

@@ -0,0 +1,79 @@
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/lib/api";
import { getSession } from "@/lib/session";
export const dynamic = "force-dynamic";
/**
* Proxy SSE do WebSocket de monitoramento (agente.md secao 54-55, 161).
* O gateway real (`RealtimeGateway`, apps/api) autentica a conexão via
* `auth.token` no handshake — um JWT bruto. Um `EventSource`/`WebSocket`
* do browser não consegue mandar esse token sem que ele passe por JS
* legível no client, o que quebraria o mesmo princípio já seguido em todo
* o resto do frontend (token só existe no cookie httpOnly). Solução: esta
* rota roda no servidor (runtime Node.js), conecta no socket.io real com o
* access token do lado do servidor, e reencaminha cada evento pro browser
* como Server-Sent Events — o `EventSource` do browser só precisa do
* cookie de sessão (same-origin, automático), nunca do token.
*/
export async function GET() {
const session = await getSession();
if (!session) return new Response(null, { status: 401 });
const encoder = new TextEncoder();
let socket: ReturnType<typeof io> | null = null;
const stream = new ReadableStream({
start(controller) {
let closed = false;
const safeEnqueue = (chunk: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(chunk));
} catch {
closed = true;
}
};
const safeClose = () => {
if (closed) return;
closed = true;
try {
controller.close();
} catch {
// já fechado pelo outro lado (client desconectou) — sem problema
}
};
socket = io(API_BASE_URL, {
path: "/realtime",
auth: { token: session.accessToken },
reconnectionAttempts: 5,
reconnectionDelay: 2000,
});
socket.on("connect", () => safeEnqueue(`event: connected\ndata: {}\n\n`));
socket.on("event", (payload: unknown) => safeEnqueue(`data: ${JSON.stringify(payload)}\n\n`));
socket.on("connect_error", (err: Error) =>
safeEnqueue(`event: upstream-error\ndata: ${JSON.stringify({ message: err.message })}\n\n`),
);
socket.on("disconnect", () => safeClose());
// Rede caiu de vez ou o token nunca vai autenticar (secao 148) — nesses
// casos manter tentando pra sempre só gastaria CPU/conexões à toa.
// O browser tenta uma conexão SSE nova sozinho (comportamento padrão
// do EventSource) se realmente for transitório.
socket.io.on("reconnect_failed", () => safeClose());
},
cancel() {
socket?.disconnect();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

View File

@@ -0,0 +1,231 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Headset, ListTree, Phone, Radio, Router } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { InstrumentTile } from "@/components/ui/instrument-tile";
import { Pill } from "@/components/ui/pill";
import { AGENT_STATE_LABELS, type Agent, type Queue } from "@/lib/callcenter-types";
import { EVENT_CATEGORY, stripDomain, type RealtimeEvent } from "@/lib/realtime-types";
type ConnectionStatus = "connecting" | "live" | "reconnecting" | "closed";
const CATEGORY_ICON: Record<string, typeof Phone> = {
call: Phone,
agent: Headset,
queue: ListTree,
extension: Router,
gateway: Router,
other: Radio,
};
function describeEvent(event: RealtimeEvent, agentNames: Record<string, string>, queueNames: Record<string, string>): string {
const agentName = (raw: unknown) => {
const id = stripDomain(String(raw ?? ""));
return (id && agentNames[id]) || String(raw ?? "—");
};
const queueName = (raw: unknown) => {
const id = stripDomain(String(raw ?? ""));
return (id && queueNames[id]) || String(raw ?? "—");
};
switch (event.type) {
case "CALL_CREATED":
return "Nova chamada criada";
case "CALL_RINGING":
return "Chamada tocando";
case "CALL_ANSWERED":
return "Chamada atendida";
case "CALL_BRIDGED":
return "Chamada conectada (bridge)";
case "CALL_UNBRIDGED":
return "Chamada desconectada (unbridge)";
case "CALL_ENDED":
return `Chamada encerrada${event.data.hangupCause ? `${event.data.hangupCause}` : ""}`;
case "EXTENSION_REGISTERED":
return `Ramal ${event.data.user ?? "?"} registrado`;
case "EXTENSION_UNREGISTERED":
return `Ramal ${event.data.user ?? "?"} desregistrado`;
case "AGENT_STATUS_CHANGED":
return `Agente ${agentName(event.data.agent)}${event.data.state ?? "?"} (mod_callcenter)`;
case "AGENT_STATE_CHANGED":
return `Agente ${agentName(event.data.agentId)}${AGENT_STATE_LABELS[String(event.data.state)] ?? event.data.state}`;
case "AGENT_OFFERED_CALL":
return `Fila ${queueName(event.data.queue)} ofereceu chamada ao agente ${agentName(event.data.agent)}`;
case "AGENT_BRIDGE_FAILED":
return `Falha ao conectar agente ${agentName(event.data.agent)} na fila ${queueName(event.data.queue)}`;
case "QUEUE_MEMBER_COUNT":
return `Fila ${queueName(event.data.queue)}: ${event.data.count ?? "?"} na espera`;
case "QUEUE_MEMBER_LEFT":
return `Fila ${queueName(event.data.queue)}: chamada saiu${event.data.cause ? ` (${event.data.cause})` : ""}`;
case "GATEWAY_UP":
return `Tronco ${event.data.gateway ?? "?"} subiu`;
case "GATEWAY_DOWN":
return `Tronco ${event.data.gateway ?? "?"} caiu`;
case "BACKGROUND_JOB_COMPLETED":
return "Job em background concluído";
default:
return event.type;
}
}
export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Agent[]; queues: Queue[] }) {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [events, setEvents] = useState<RealtimeEvent[]>([]);
const [agentState, setAgentState] = useState<Record<string, string>>(() =>
Object.fromEntries(initialAgents.map((a) => [a.id, a.state])),
);
const [queueCounts, setQueueCounts] = useState<Record<string, number>>({});
const [tallies, setTallies] = useState({ created: 0, answered: 0, ended: 0 });
const agentNames = useMemo(() => Object.fromEntries(initialAgents.map((a) => [a.id, a.name])), [initialAgents]);
const queueNames = useMemo(() => Object.fromEntries(queues.map((q) => [q.id, q.name])), [queues]);
const eventsRef = useRef<RealtimeEvent[]>([]);
useEffect(() => {
const source = new EventSource("/api/monitoring/stream");
source.addEventListener("connected", () => setStatus("live"));
source.addEventListener("upstream-error", () => setStatus("reconnecting"));
source.onmessage = (message) => {
let event: RealtimeEvent;
try {
event = JSON.parse(message.data);
} catch {
return;
}
eventsRef.current = [event, ...eventsRef.current].slice(0, 50);
setEvents(eventsRef.current);
if (event.type === "CALL_CREATED") setTallies((t) => ({ ...t, created: t.created + 1 }));
if (event.type === "CALL_ANSWERED") setTallies((t) => ({ ...t, answered: t.answered + 1 }));
if (event.type === "CALL_ENDED") setTallies((t) => ({ ...t, ended: t.ended + 1 }));
if (event.type === "AGENT_STATE_CHANGED") {
const agentId = String(event.data.agentId ?? "");
if (agentId) setAgentState((prev) => ({ ...prev, [agentId]: String(event.data.state) }));
}
if (event.type === "QUEUE_MEMBER_COUNT") {
const queueId = stripDomain(String(event.data.queue ?? ""));
if (queueId && typeof event.data.count === "number") {
setQueueCounts((prev) => ({ ...prev, [queueId]: event.data.count as number }));
}
}
};
source.onerror = () => {
setStatus((prev) => (prev === "closed" ? prev : "reconnecting"));
};
return () => {
source.close();
};
}, []);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">Monitoramento em tempo real</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Eventos ao vivo do FreeSWITCH pra este tenant (agente.md secao 54-55, 161) WebSocket
tenant-scoped no servidor, nunca um broadcast global filtrado no browser. Contadores desta seção
zeram a cada vez que a página é recarregada, não são um total histórico.
</p>
</div>
<ConnectionBadge status={status} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<InstrumentTile label="Chamadas criadas" value={tallies.created} live={status === "live"} />
<InstrumentTile label="Chamadas atendidas" value={tallies.answered} live={status === "live"} />
<InstrumentTile label="Chamadas encerradas" value={tallies.ended} live={status === "live"} />
</div>
<Panel>
<PanelHeader title="Filas ao vivo" description="Pessoas na espera — atualiza a cada evento QUEUE_MEMBER_COUNT" />
{queues.length === 0 ? (
<p className="px-5 py-4 text-sm text-muted-foreground">Nenhuma fila cadastrada ainda.</p>
) : (
<div className="grid grid-cols-1 gap-4 p-5 sm:grid-cols-3">
{queues.map((q) => (
<InstrumentTile
key={q.id}
label={q.name}
value={queueCounts[q.id] ?? null}
pending="Sem evento de fila ainda nesta sessão"
live={status === "live" && queueCounts[q.id] != null}
/>
))}
</div>
)}
</Panel>
<Panel>
<PanelHeader title="Agentes" description="Estado atual — muda ao vivo com login/pausa/logout" />
{initialAgents.length === 0 ? (
<p className="px-5 py-4 text-sm text-muted-foreground">Nenhum agente cadastrado ainda.</p>
) : (
<ul className="divide-y divide-border">
{initialAgents.map((a) => (
<li key={a.id} className="flex items-center justify-between px-5 py-3 text-sm">
<span className="flex items-center gap-2 text-foreground">
<Headset className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{a.name}
</span>
<Pill tone={agentState[a.id] === "AVAILABLE" ? "accent" : "neutral"}>
{AGENT_STATE_LABELS[agentState[a.id]] ?? agentState[a.id]}
</Pill>
</li>
))}
</ul>
)}
</Panel>
<Panel>
<PanelHeader title="Eventos ao vivo" description="Últimos 50 eventos, mais recente primeiro" />
{events.length === 0 ? (
<p className="px-5 py-4 text-sm text-muted-foreground">
{status === "live" ? "Conectado — aguardando o primeiro evento." : "Conectando ao monitoramento…"}
</p>
) : (
<ul className="max-h-[420px] divide-y divide-border overflow-y-auto">
{events.map((event, i) => {
const category = EVENT_CATEGORY[event.type] ?? "other";
const Icon = CATEGORY_ICON[category] ?? Radio;
return (
<li key={`${event.occurredAt}-${i}`} className="flex items-start gap-3 px-5 py-2.5 text-sm">
<Icon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 text-foreground">{describeEvent(event, agentNames, queueNames)}</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{new Date(event.occurredAt).toLocaleTimeString("pt-BR")}
</span>
</li>
);
})}
</ul>
)}
</Panel>
</div>
);
}
function ConnectionBadge({ status }: { status: ConnectionStatus }) {
const config: Record<ConnectionStatus, { label: string; dot: string }> = {
connecting: { label: "Conectando…", dot: "bg-status-yellow" },
live: { label: "Ao vivo", dot: "bg-status-green animate-pulse-live" },
reconnecting: { label: "Reconectando…", dot: "bg-status-yellow" },
closed: { label: "Desconectado", dot: "bg-status-red" },
};
const c = config[status];
return (
<span className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
<span className={`h-2 w-2 rounded-full ${c.dot}`} aria-hidden />
{c.label}
</span>
);
}

View File

@@ -0,0 +1,15 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { Agent, Queue } from "@/lib/callcenter-types";
import { MonitoramentoView } from "./monitoramento-view";
export default async function MonitoramentoPage() {
const session = await requireSession();
const [agents, queues] = await Promise.all([
apiFetch<Agent[]>("/agents", session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]);
return <MonitoramentoView initialAgents={agents} queues={queues} />;
}

View File

@@ -97,7 +97,8 @@ export const TENANT_NAV: NavSection[] = [
{
label: "Monitoramento",
icon: Radar,
children: [{ label: "Campanhas" }, { label: "Filas" }, { label: "Agentes" }, { label: "Ramais" }, { label: "Troncos" }],
href: "/app/monitoramento",
description: "Eventos ao vivo — filas, agentes e chamadas em tempo real",
},
{
label: "Gravações",

View File

@@ -0,0 +1,36 @@
export interface RealtimeEvent {
type: string;
occurredAt: string;
callUuid?: string;
tenantId?: string;
b2bcallCallId?: string;
b2bcallCampaignId?: string;
data: Record<string, unknown>;
}
/** `agent`/`queue` do mod_callcenter vêm como "<id>@<domain>" (secao 45,
* 50); `agentId` de AGENT_STATE_CHANGED já vem cru (publicado pela API,
* não pelo FreeSWITCH). Uma função só resolve os dois formatos. */
export function stripDomain(value: string | undefined): string | undefined {
return value?.split("@")[0];
}
export const EVENT_CATEGORY: Record<string, "call" | "agent" | "queue" | "extension" | "gateway" | "other"> = {
CALL_CREATED: "call",
CALL_RINGING: "call",
CALL_ANSWERED: "call",
CALL_BRIDGED: "call",
CALL_UNBRIDGED: "call",
CALL_ENDED: "call",
EXTENSION_REGISTERED: "extension",
EXTENSION_UNREGISTERED: "extension",
AGENT_STATUS_CHANGED: "agent",
AGENT_STATE_CHANGED: "agent",
AGENT_OFFERED_CALL: "agent",
AGENT_BRIDGE_FAILED: "agent",
QUEUE_MEMBER_COUNT: "queue",
QUEUE_MEMBER_LEFT: "queue",
GATEWAY_UP: "gateway",
GATEWAY_DOWN: "gateway",
BACKGROUND_JOB_COMPLETED: "other",
};