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:
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
@@ -9,30 +9,31 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@tanstack/react-query": "5.62.7",
|
||||
"@tanstack/react-table": "8.20.5",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.469.0",
|
||||
"next": "15.3.9",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"@tanstack/react-query": "5.62.7",
|
||||
"@tanstack/react-table": "8.20.5",
|
||||
"recharts": "2.15.0",
|
||||
"lucide-react": "0.469.0",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "2.5.5",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"server-only": "0.0.1"
|
||||
"server-only": "0.0.1",
|
||||
"socket.io-client": "4.8.3",
|
||||
"tailwind-merge": "2.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.4.49",
|
||||
"autoprefixer": "10.4.20"
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
79
apps/frontend/src/app/api/monitoring/stream/route.ts
Normal file
79
apps/frontend/src/app/api/monitoring/stream/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
231
apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx
Normal file
231
apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
apps/frontend/src/app/app/monitoramento/page.tsx
Normal file
15
apps/frontend/src/app/app/monitoramento/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
36
apps/frontend/src/lib/realtime-types.ts
Normal file
36
apps/frontend/src/lib/realtime-types.ts
Normal 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",
|
||||
};
|
||||
Reference in New Issue
Block a user