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:
63
TODO.md
63
TODO.md
@@ -1113,6 +1113,69 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71,
|
||||
decisão de escopo pra manter o formulário enxuto nesta primeira
|
||||
versão
|
||||
|
||||
## PHASE 32 — Frontend: Monitoramento em tempo real
|
||||
(agente.md secao 54-55, 161)
|
||||
- [x] **Achado de arquitetura, resolvido antes de codar**: o
|
||||
`RealtimeGateway` (backend, já existia desde a PHASE 13) autentica a
|
||||
conexão socket.io via `auth.token` no handshake — um JWT bruto. Um
|
||||
`EventSource`/`WebSocket` do browser não tem como mandar esse token
|
||||
sem ele passar por JS legível no client, quebrando o mesmo princípio
|
||||
seguido em todo o resto do frontend (o access token só existe no
|
||||
cookie httpOnly). Resolvido com um proxy: `apps/frontend/src/app/
|
||||
api/monitoring/stream/route.ts` roda no servidor (runtime Node.js),
|
||||
conecta no socket.io real com o access token do lado do servidor
|
||||
(`socket.io-client`, dependência nova só usada aqui), e reencaminha
|
||||
cada evento pro browser como Server-Sent Events — o `EventSource` do
|
||||
client só precisa do cookie de sessão (same-origin, automático),
|
||||
nunca do token. Como bônus, elimina qualquer necessidade de mexer em
|
||||
`CORS_ORIGIN` (a conexão socket.io real é servidor-servidor, nunca
|
||||
passa pelo browser).
|
||||
- [x] `/app/monitoramento`: badge de conexão (Conectando/Ao vivo/
|
||||
Reconectando), 3 contadores de sessão (chamadas criadas/atendidas/
|
||||
encerradas, zeram a cada reload — não são um total histórico),
|
||||
grade de filas ao vivo (contagem de espera via `QUEUE_MEMBER_COUNT`,
|
||||
traço fantasma até o primeiro evento — sem endpoint de "contagem
|
||||
atual" pra semear um valor inicial), lista de agentes com estado ao
|
||||
vivo (semeada do `GET /agents` inicial, atualizada via
|
||||
`AGENT_STATE_CHANGED`), e feed dos últimos 50 eventos com descrição
|
||||
legível por tipo.
|
||||
- [x] Menu Tenant: "Monitoramento" deixa de ter 5 sub-itens placeholder
|
||||
(Campanhas/Filas/Agentes/Ramais/Troncos) e vira 1 link direto — o
|
||||
painel novo já cobre filas+agentes+eventos gerais numa página só;
|
||||
abrir em 5 rotas separadas duplicaria a conexão SSE sem necessidade
|
||||
real (mesma decisão já tomada nas Relatórios: 1 `href` por item de
|
||||
menu, nunca vários apontando pro mesmo lugar).
|
||||
- [x] `agent`/`queue` do mod_callcenter chegam como `"<id>@<domain>"`
|
||||
(secao 45, 50) — `stripDomain()` novo em `lib/realtime-types.ts`
|
||||
separa o id antes de resolver nome; `AGENT_STATE_CHANGED` (publicado
|
||||
pela própria API, não pelo FreeSWITCH) já vem com `agentId` cru, sem
|
||||
sufixo.
|
||||
- [x] Testado ponta a ponta contra o pipeline real (não simulado): um
|
||||
cliente socket.io cru confirmou primeiro que a API já entrega o
|
||||
evento certo (`AGENT_STATE_CHANGED`) ao vivo; a mesma verificação
|
||||
via `curl -N` direto no proxy SSE confirmou o reencaminhamento;
|
||||
depois, com a página `/app/monitoramento` aberta de verdade num
|
||||
browser (Puppeteer), disparado `POST /agents/me/login` e
|
||||
`/logout` por fora — o badge do agente mudou de Offline pra
|
||||
"Disponível" e voltou, e os dois eventos apareceram no feed ao
|
||||
vivo, tudo em tempo real sem recarregar a página. Smoke test de
|
||||
regressão nas 19 telas anteriores do tenant + platform, todas 200
|
||||
(a rota de monitoramento mantém uma conexão aberta de propósito,
|
||||
então usa `domcontentloaded` em vez de `networkidle0` no teste).
|
||||
- [ ] Sem reconciliação/snapshot ao conectar (secao 55: "estado inicial
|
||||
completo, não só eventos a partir de agora") — filas começam sem
|
||||
dado até o primeiro `QUEUE_MEMBER_COUNT`; agentes começam certos
|
||||
porque semeiam do `GET /agents` inicial, mas filas não têm
|
||||
equivalente (nenhum endpoint retorna "contagem atual" fora do
|
||||
próprio stream de eventos)
|
||||
- [ ] Sem monitoramento de ramais (registro/busy, secao 55 completa) nem
|
||||
de campanhas/troncos em tempo real — cobertos só pelos relatórios
|
||||
de período, não por esta tela
|
||||
- [ ] Reconexão do `EventSource` é o comportamento padrão do browser
|
||||
(tenta de novo sozinho); o lado do servidor limita a 5 tentativas de
|
||||
reconexão do socket.io real antes de fechar o stream, mas não há
|
||||
backoff exponencial nem um teste de queda de rede prolongada
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
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",
|
||||
};
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -237,6 +237,9 @@ importers:
|
||||
server-only:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
socket.io-client:
|
||||
specifier: 4.8.3
|
||||
version: 4.8.3
|
||||
tailwind-merge:
|
||||
specifier: 2.5.5
|
||||
version: 2.5.5
|
||||
@@ -2028,6 +2031,9 @@ packages:
|
||||
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
engine.io-client@6.6.6:
|
||||
resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==}
|
||||
|
||||
engine.io-parser@5.2.3:
|
||||
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -2777,6 +2783,10 @@ packages:
|
||||
socket.io-adapter@2.5.8:
|
||||
resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==}
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
socket.io-parser@4.2.7:
|
||||
resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -2973,6 +2983,10 @@ packages:
|
||||
resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2:
|
||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -4548,6 +4562,18 @@ snapshots:
|
||||
|
||||
empathic@2.0.0: {}
|
||||
|
||||
engine.io-client@6.6.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.21.3
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
engine.io-parser@5.2.3: {}
|
||||
|
||||
engine.io@6.6.9:
|
||||
@@ -5311,6 +5337,17 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3
|
||||
engine.io-client: 6.6.6
|
||||
socket.io-parser: 4.2.7
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
socket.io-parser@4.2.7:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
@@ -5509,6 +5546,8 @@ snapshots:
|
||||
|
||||
xml-naming@0.3.0: {}
|
||||
|
||||
xmlhttprequest-ssl@2.1.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
Reference in New Issue
Block a user