feat(monitoramento): status de registro dos ramais (online/offline) em tempo real

Pedido do usuário: "antes de ir para IVR no monitoramento tem que mostrar
quantos ramais estão online e o status de cada ramal criado".

apps/api roda no host e não alcança o ESL do FreeSWITCH diretamente
(freeswitch:8021 só existe na rede interna do Docker) — mesmo padrão já
documentado em platform-freeswitch.controller.ts. Por isso, seguido o
mesmo caminho já usado por Trunk.status: apps/freeswitch-events (conexão
ESL permanente) já consumia sofia::register/unregister/expire pra outros
fins, então virou a fonte de verdade — grava `Extension.registeredAt` a
cada evento, mais uma reconciliação completa (`show registrations`) ao
conectar/reconectar no ESL pra não ficar com dado desatualizado se o
serviço caiu no meio de uma sessão de registro.

GET /extensions agora devolve `registeredAt`; a página /app/monitoramento
ganhou um painel "Ramais" com status ao vivo (online/offline, desde
quando, grupo de captura) e um instrumento "Ramais online: X de Y" — a
mesma conexão SSE que já existia só precisou aprender a atualizar esse
novo estado a partir dos eventos EXTENSION_REGISTERED/UNREGISTERED, que
já estavam sendo publicados e só apareciam no log de eventos.

Testado: reconciliação confirmada rodando no container real (2 ramais já
registrados antes do restart do fs-events foram marcados online
corretamente); GET /extensions confirmado devolvendo registeredAt (null
num ramal recém-criado, sem registro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 16:02:14 -03:00
parent 2cd638d26f
commit fc4f5db13a
8 changed files with 193 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ 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 type { Extension } from "@/lib/extension-types";
import { EVENT_CATEGORY, stripDomain, type RealtimeEvent } from "@/lib/realtime-types";
type ConnectionStatus = "connecting" | "live" | "reconnecting" | "closed";
@@ -69,7 +70,15 @@ function describeEvent(event: RealtimeEvent, agentNames: Record<string, string>,
}
}
export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Agent[]; queues: Queue[] }) {
export function MonitoramentoView({
initialAgents,
queues,
initialExtensions,
}: {
initialAgents: Agent[];
queues: Queue[];
initialExtensions: Extension[];
}) {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [events, setEvents] = useState<RealtimeEvent[]>([]);
const [agentState, setAgentState] = useState<Record<string, string>>(() =>
@@ -77,9 +86,21 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
);
const [queueCounts, setQueueCounts] = useState<Record<string, number>>({});
const [tallies, setTallies] = useState({ created: 0, answered: 0, ended: 0 });
// Ramal está online quando `registeredAt` não é null (secao 55 —
// apps/freeswitch-events mantém isso via sofia::register/unregister/
// expire, com reconciliação ao conectar no ESL). Chave = número do
// ramal, que é o mesmo valor que `EXTENSION_REGISTERED`/
// `EXTENSION_UNREGISTERED` trazem em `data.user` (sem domínio).
const [extensionRegisteredAt, setExtensionRegisteredAt] = useState<Record<string, string | null>>(() =>
Object.fromEntries(initialExtensions.map((e) => [e.number, e.registeredAt])),
);
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 onlineExtensionCount = useMemo(
() => Object.values(extensionRegisteredAt).filter((v) => v != null).length,
[extensionRegisteredAt],
);
const eventsRef = useRef<RealtimeEvent[]>([]);
@@ -115,6 +136,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
setQueueCounts((prev) => ({ ...prev, [queueId]: event.data.count as number }));
}
}
if (event.type === "EXTENSION_REGISTERED" || event.type === "EXTENSION_UNREGISTERED") {
const number = String(event.data.user ?? "");
if (number) {
setExtensionRegisteredAt((prev) => ({
...prev,
[number]: event.type === "EXTENSION_REGISTERED" ? event.occurredAt : null,
}));
}
}
};
source.onerror = () => {
@@ -140,10 +171,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
<ConnectionBadge status={status} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<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"} />
<InstrumentTile
label="Ramais online"
value={onlineExtensionCount}
suffix={`de ${initialExtensions.length}`}
live={status === "live"}
/>
</div>
<Panel>
@@ -186,6 +223,41 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
)}
</Panel>
<Panel>
<PanelHeader
title="Ramais"
description="Status de registro SIP — muda ao vivo com REGISTER/UNREGISTER de cada aparelho"
/>
{initialExtensions.length === 0 ? (
<p className="px-5 py-4 text-sm text-muted-foreground">Nenhum ramal cadastrado ainda.</p>
) : (
<ul className="divide-y divide-border">
{initialExtensions.map((ext) => {
const registeredAt = extensionRegisteredAt[ext.number] ?? null;
const online = registeredAt != null;
return (
<li key={ext.id} className="flex items-center justify-between gap-3 px-5 py-3 text-sm">
<span className="flex items-center gap-2 text-foreground">
<Router className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
<span className="font-mono">{ext.number}</span>
<span className="text-muted-foreground">{ext.name}</span>
{ext.callGroup && <Pill tone="neutral">grupo {ext.callGroup}</Pill>}
</span>
<span className="flex items-center gap-2">
{online && (
<span className="font-mono text-xs text-muted-foreground">
desde {new Date(registeredAt).toLocaleTimeString("pt-BR")}
</span>
)}
<Pill tone={online ? "accent" : "neutral"}>{online ? "Online" : "Offline"}</Pill>
</span>
</li>
);
})}
</ul>
)}
</Panel>
<Panel>
<PanelHeader title="Eventos ao vivo" description="Últimos 50 eventos, mais recente primeiro" />
{events.length === 0 ? (

View File

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

View File

@@ -11,6 +11,7 @@ export interface Extension {
codecs: string;
callGroup: string | null;
maxRegistrations: number;
registeredAt: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;