diff --git a/TODO.md b/TODO.md index 48df89d..82fec9a 100644 --- a/TODO.md +++ b/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 `"@"` + (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 diff --git a/apps/frontend/.impeccable/review/monitoramento-connecting-desktop.png b/apps/frontend/.impeccable/review/monitoramento-connecting-desktop.png new file mode 100644 index 0000000..eb79543 Binary files /dev/null and b/apps/frontend/.impeccable/review/monitoramento-connecting-desktop.png differ diff --git a/apps/frontend/.impeccable/review/monitoramento-live-event-desktop.png b/apps/frontend/.impeccable/review/monitoramento-live-event-desktop.png new file mode 100644 index 0000000..d3ebf19 Binary files /dev/null and b/apps/frontend/.impeccable/review/monitoramento-live-event-desktop.png differ diff --git a/apps/frontend/.impeccable/review/monitoramento-live-event2-desktop.png b/apps/frontend/.impeccable/review/monitoramento-live-event2-desktop.png new file mode 100644 index 0000000..89388ed Binary files /dev/null and b/apps/frontend/.impeccable/review/monitoramento-live-event2-desktop.png differ diff --git a/apps/frontend/package.json b/apps/frontend/package.json index bb5f0a2..b0c870d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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" } } diff --git a/apps/frontend/src/app/api/monitoring/stream/route.ts b/apps/frontend/src/app/api/monitoring/stream/route.ts new file mode 100644 index 0000000..0e16ddb --- /dev/null +++ b/apps/frontend/src/app/api/monitoring/stream/route.ts @@ -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 | 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", + }, + }); +} diff --git a/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx b/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx new file mode 100644 index 0000000..40ad04b --- /dev/null +++ b/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx @@ -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 = { + call: Phone, + agent: Headset, + queue: ListTree, + extension: Router, + gateway: Router, + other: Radio, +}; + +function describeEvent(event: RealtimeEvent, agentNames: Record, queueNames: Record): 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("connecting"); + const [events, setEvents] = useState([]); + const [agentState, setAgentState] = useState>(() => + Object.fromEntries(initialAgents.map((a) => [a.id, a.state])), + ); + const [queueCounts, setQueueCounts] = useState>({}); + 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([]); + + 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 ( +
+
+
+

Monitoramento em tempo real

+

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

+
+ +
+ +
+ + + +
+ + + + {queues.length === 0 ? ( +

Nenhuma fila cadastrada ainda.

+ ) : ( +
+ {queues.map((q) => ( + + ))} +
+ )} +
+ + + + {initialAgents.length === 0 ? ( +

Nenhum agente cadastrado ainda.

+ ) : ( +
    + {initialAgents.map((a) => ( +
  • + + + {a.name} + + + {AGENT_STATE_LABELS[agentState[a.id]] ?? agentState[a.id]} + +
  • + ))} +
+ )} +
+ + + + {events.length === 0 ? ( +

+ {status === "live" ? "Conectado — aguardando o primeiro evento." : "Conectando ao monitoramento…"} +

+ ) : ( +
    + {events.map((event, i) => { + const category = EVENT_CATEGORY[event.type] ?? "other"; + const Icon = CATEGORY_ICON[category] ?? Radio; + return ( +
  • + + {describeEvent(event, agentNames, queueNames)} + + {new Date(event.occurredAt).toLocaleTimeString("pt-BR")} + +
  • + ); + })} +
+ )} +
+
+ ); +} + +function ConnectionBadge({ status }: { status: ConnectionStatus }) { + const config: Record = { + 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 ( + + + {c.label} + + ); +} diff --git a/apps/frontend/src/app/app/monitoramento/page.tsx b/apps/frontend/src/app/app/monitoramento/page.tsx new file mode 100644 index 0000000..425649a --- /dev/null +++ b/apps/frontend/src/app/app/monitoramento/page.tsx @@ -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("/agents", session.accessToken), + apiFetch("/queues", session.accessToken), + ]); + + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 9c273b6..cd5845a 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -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", diff --git a/apps/frontend/src/lib/realtime-types.ts b/apps/frontend/src/lib/realtime-types.ts new file mode 100644 index 0000000..7102b05 --- /dev/null +++ b/apps/frontend/src/lib/realtime-types.ts @@ -0,0 +1,36 @@ +export interface RealtimeEvent { + type: string; + occurredAt: string; + callUuid?: string; + tenantId?: string; + b2bcallCallId?: string; + b2bcallCampaignId?: string; + data: Record; +} + +/** `agent`/`queue` do mod_callcenter vêm como "@" (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 = { + 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", +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 054d2ff..bd014a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}