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:
@@ -35,6 +35,7 @@ function toPublicExtension(ext: {
|
|||||||
codecs: string;
|
codecs: string;
|
||||||
callGroup: string | null;
|
callGroup: string | null;
|
||||||
maxRegistrations: number;
|
maxRegistrations: number;
|
||||||
|
registeredAt: Date | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
87
apps/freeswitch-events/src/extension-status.ts
Normal file
87
apps/freeswitch-events/src/extension-status.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||||
|
|
||||||
|
const logger = createLogger("b2bcall-fs-events");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* sofia::register/unregister/expire não carregam `b2bcall_tenant_id` (só
|
||||||
|
* existe como channel variable de CHAMADA, nunca de REGISTER) — resolve
|
||||||
|
* por domínio (from-host), único por tenant desde a PHASE 52/53. Mesmo
|
||||||
|
* padrão de fan-out do `updateTrunkStatusFromGatewayEvent`, mas aqui o
|
||||||
|
* domínio já identifica o tenant direto, sem precisar tentar cada um.
|
||||||
|
*/
|
||||||
|
export async function updateExtensionRegistrationStatus(
|
||||||
|
user: string | undefined,
|
||||||
|
host: string | undefined,
|
||||||
|
registered: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!user || !host) return;
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: host }, select: { id: true } });
|
||||||
|
if (!tenant) {
|
||||||
|
logger.debug("nenhum tenant encontrado pro dominio do REGISTER", { host, user });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||||
|
tx.extension.updateMany({
|
||||||
|
where: { number: user, tenantId: tenant.id, deletedAt: null },
|
||||||
|
data: { registeredAt: registered ? new Date() : null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count > 0) {
|
||||||
|
logger.info("status de registro do ramal atualizado", { user, host, registered });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciliação ao conectar/reconectar no ESL (secao 55): sem isto, um
|
||||||
|
* ramal que já estava registrado ANTES do fs-events subir (ou durante uma
|
||||||
|
* queda do serviço) ficaria com `registered_at` desatualizado até o
|
||||||
|
* próximo REGISTER natural do aparelho — minutos de atraso, dependendo do
|
||||||
|
* `registration-expires` configurado no softphone. `show registrations`
|
||||||
|
* é a fonte de verdade ao vivo: zera todo mundo do tenant primeiro,
|
||||||
|
* depois marca só quem está de fato na lista agora.
|
||||||
|
*/
|
||||||
|
export async function reconcileExtensionRegistrations(provider: FreeSwitchTelephonyProvider): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await provider.getRegistrations();
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("falha ao consultar registrations pra reconciliacao", { error: String(err) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (raw as { rows?: Array<{ reg_user?: string; realm?: string }> } | undefined)?.rows ?? [];
|
||||||
|
const registeredByDomain = new Map<string, Set<string>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.reg_user || !row.realm) continue;
|
||||||
|
if (!registeredByDomain.has(row.realm)) registeredByDomain.set(row.realm, new Set());
|
||||||
|
registeredByDomain.get(row.realm)!.add(row.reg_user);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where: { status: "ACTIVE", telephonyDomain: { not: null } },
|
||||||
|
select: { id: true, telephonyDomain: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const tenant of tenants) {
|
||||||
|
const registeredNumbers = Array.from(registeredByDomain.get(tenant.telephonyDomain!) ?? []);
|
||||||
|
await withTenantContext(prisma, tenant.id, async (tx) => {
|
||||||
|
await tx.extension.updateMany({
|
||||||
|
where: { tenantId: tenant.id, deletedAt: null, number: { notIn: registeredNumbers } },
|
||||||
|
data: { registeredAt: null },
|
||||||
|
});
|
||||||
|
if (registeredNumbers.length > 0) {
|
||||||
|
await tx.extension.updateMany({
|
||||||
|
where: { tenantId: tenant.id, deletedAt: null, number: { in: registeredNumbers } },
|
||||||
|
data: { registeredAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.info("reconciliacao de registrations de ramais concluida", { tenantsChecked: tenants.length });
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FreeSwitchEventData } from "esl";
|
|||||||
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
||||||
|
import { updateExtensionRegistrationStatus, reconcileExtensionRegistrations } from "./extension-status";
|
||||||
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
|
||||||
import { persistCallEvent } from "./cdr";
|
import { persistCallEvent } from "./cdr";
|
||||||
import { uploadRecordingIfPresent } from "./recording";
|
import { uploadRecordingIfPresent } from "./recording";
|
||||||
@@ -90,6 +91,10 @@ async function main() {
|
|||||||
call.on(eventName, (raw) => handleEvent(eventName, raw));
|
call.on(eventName, (raw) => handleEvent(eventName, raw));
|
||||||
}
|
}
|
||||||
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
|
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
|
||||||
|
|
||||||
|
reconcileExtensionRegistrations(provider).catch((err) => {
|
||||||
|
logger.error("falha na reconciliacao de registrations de ramais", { error: String(err) });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("reconnecting", (retryMs) => {
|
client.on("reconnecting", (retryMs) => {
|
||||||
@@ -170,6 +175,14 @@ async function main() {
|
|||||||
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
|
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.type === "EXTENSION_REGISTERED" || normalized.type === "EXTENSION_UNREGISTERED") {
|
||||||
|
const user = normalized.data.user as string | undefined;
|
||||||
|
const host = normalized.data.host as string | undefined;
|
||||||
|
updateExtensionRegistrationStatus(user, host, normalized.type === "EXTENSION_REGISTERED").catch((err) => {
|
||||||
|
logger.error("falha ao atualizar status de registro do ramal", { error: String(err), user, host });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.connect();
|
provider.connect();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
|
|||||||
import { InstrumentTile } from "@/components/ui/instrument-tile";
|
import { InstrumentTile } from "@/components/ui/instrument-tile";
|
||||||
import { Pill } from "@/components/ui/pill";
|
import { Pill } from "@/components/ui/pill";
|
||||||
import { AGENT_STATE_LABELS, type Agent, type Queue } from "@/lib/callcenter-types";
|
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";
|
import { EVENT_CATEGORY, stripDomain, type RealtimeEvent } from "@/lib/realtime-types";
|
||||||
|
|
||||||
type ConnectionStatus = "connecting" | "live" | "reconnecting" | "closed";
|
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 [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||||
const [events, setEvents] = useState<RealtimeEvent[]>([]);
|
const [events, setEvents] = useState<RealtimeEvent[]>([]);
|
||||||
const [agentState, setAgentState] = useState<Record<string, string>>(() =>
|
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 [queueCounts, setQueueCounts] = useState<Record<string, number>>({});
|
||||||
const [tallies, setTallies] = useState({ created: 0, answered: 0, ended: 0 });
|
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 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 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[]>([]);
|
const eventsRef = useRef<RealtimeEvent[]>([]);
|
||||||
|
|
||||||
@@ -115,6 +136,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
|
|||||||
setQueueCounts((prev) => ({ ...prev, [queueId]: event.data.count as number }));
|
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 = () => {
|
source.onerror = () => {
|
||||||
@@ -140,10 +171,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
|
|||||||
<ConnectionBadge status={status} />
|
<ConnectionBadge status={status} />
|
||||||
</div>
|
</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 criadas" value={tallies.created} live={status === "live"} />
|
||||||
<InstrumentTile label="Chamadas atendidas" value={tallies.answered} 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="Chamadas encerradas" value={tallies.ended} live={status === "live"} />
|
||||||
|
<InstrumentTile
|
||||||
|
label="Ramais online"
|
||||||
|
value={onlineExtensionCount}
|
||||||
|
suffix={`de ${initialExtensions.length}`}
|
||||||
|
live={status === "live"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -186,6 +223,41 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</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>
|
<Panel>
|
||||||
<PanelHeader title="Eventos ao vivo" description="Últimos 50 eventos, mais recente primeiro" />
|
<PanelHeader title="Eventos ao vivo" description="Últimos 50 eventos, mais recente primeiro" />
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import type { Agent, Queue } from "@/lib/callcenter-types";
|
import type { Agent, Queue } from "@/lib/callcenter-types";
|
||||||
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { MonitoramentoView } from "./monitoramento-view";
|
import { MonitoramentoView } from "./monitoramento-view";
|
||||||
|
|
||||||
export default async function MonitoramentoPage() {
|
export default async function MonitoramentoPage() {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
|
|
||||||
const [agents, queues] = await Promise.all([
|
const [agents, queues, extensions] = await Promise.all([
|
||||||
apiFetch<Agent[]>("/agents", session.accessToken),
|
apiFetch<Agent[]>("/agents", session.accessToken),
|
||||||
apiFetch<Queue[]>("/queues", 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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Extension {
|
|||||||
codecs: string;
|
codecs: string;
|
||||||
callGroup: string | null;
|
callGroup: string | null;
|
||||||
maxRegistrations: number;
|
maxRegistrations: number;
|
||||||
|
registeredAt: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- PHASE 55: status de registro SIP em tempo real (monitoramento de ramais)
|
||||||
|
ALTER TABLE "extensions" ADD COLUMN "registered_at" TIMESTAMP(3);
|
||||||
@@ -321,6 +321,17 @@ model Extension {
|
|||||||
|
|
||||||
maxRegistrations Int @default(1) @map("max_registrations")
|
maxRegistrations Int @default(1) @map("max_registrations")
|
||||||
|
|
||||||
|
// Status de registro SIP em tempo real (PHASE 55, monitoramento —
|
||||||
|
// achado real: usuário pediu "quantos ramais estão online e o status de
|
||||||
|
// cada" antes de ir pro IVR). Preenchido pelo `apps/freeswitch-events`
|
||||||
|
// a cada `sofia::register`/`sofia::unregister`/`sofia::expire` — null =
|
||||||
|
// não registrado agora. Não dá pra consultar o ESL direto de
|
||||||
|
// `apps/api` pra isso (roda no host, `freeswitch:8021` só existe na
|
||||||
|
// rede interna do Docker, ver docs/FREESWITCH.md), mas fs-events já
|
||||||
|
// tem conexão ESL permanente e já consome esses eventos pra outros
|
||||||
|
// fins — mesmo padrão já usado em `Trunk.status`/`statusUpdatedAt`.
|
||||||
|
registeredAt DateTime? @map("registered_at")
|
||||||
|
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
Reference in New Issue
Block a user