feat(frontend): Platform > Infraestrutura > FreeSWITCH/SIP Profiles/Nodes

GET /platform/freeswitch/channels|profiles|nodes fazem introspecção ESL real
(show channels/show calls/sofia status/show registrations/status/show gateways),
reaproveitando os métodos do FreeSwitchTelephonyProvider já verificados manualmente
contra o FreeSWITCH real. Mesmo padrão de conexão avulsa do PlatformHealthController
(connect → comando → disconnect).

Nunca deixa a indisponibilidade do ESL virar 500/503 — devolve { ok: false, error }
com 200, mesma filosofia do health check. Necessário: nesta VM apps/api roda fora do
Docker e a porta 8021 é deliberadamente não publicada no host, então as 3 telas
sempre mostram essa explicação aqui (mesmo texto já usado em Infraestrutura >
Saúde), mesmo com o endpoint 100% funcional — confirmado indiretamente pelo
b2bcall-fs-events, que fala ESL de dentro da rede Docker e está com heartbeat ativo.

"Nodes" mostra explicitamente 1 node (container único, sem clustering) em vez de
fingir uma lista.

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 08:41:49 -03:00
parent b1a409da09
commit 12af12f276
15 changed files with 303 additions and 3 deletions

View File

@@ -0,0 +1,22 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
export function EslUnavailableNotice({ error }: { error: string }) {
return (
<Panel>
<PanelHeader title="ESL indisponível" />
<div className="space-y-3 px-5 py-4 text-sm text-muted-foreground">
<p>
Erro retornado: <span className="font-mono text-xs text-destructive">{error}</span>
</p>
<p>
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker; a porta
do Event Socket (8021) do FreeSWITCH é deliberadamente <strong>não publicada no host</strong> (agente.md
secao 184: porta sensível, nunca exposta). Por isso este endpoint sempre falha aqui, mesmo com o
FreeSWITCH saudável o container está isolado do jeito certo. Em produção, onde{" "}
<code className="font-mono text-xs">apps/api</code> roda na mesma rede Docker do FreeSWITCH, esta tela
mostra os dados reais.
</p>
</div>
</Panel>
);
}

View File

@@ -0,0 +1,36 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import type { EslResult, FreeswitchChannels } from "@/lib/platform-types";
import { EslUnavailableNotice } from "../esl-notice";
export function FreeswitchView({ result }: { result: EslResult<FreeswitchChannels> }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">FreeSWITCH</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Canais e chamadas ativas agora, direto do ESL (<code className="font-mono text-xs">show channels</code>/
<code className="font-mono text-xs">show calls</code>) leitura ao vivo, não histórico.
</p>
</div>
{!result.ok ? (
<EslUnavailableNotice error={result.error} />
) : (
<>
<Panel>
<PanelHeader title="Canais ativos" />
<pre className="max-h-96 overflow-auto px-5 py-4 font-mono text-xs text-muted-foreground">
{JSON.stringify(result.data.channels, null, 2)}
</pre>
</Panel>
<Panel>
<PanelHeader title="Chamadas ativas" />
<pre className="max-h-96 overflow-auto px-5 py-4 font-mono text-xs text-muted-foreground">
{JSON.stringify(result.data.calls, null, 2)}
</pre>
</Panel>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { EslResult, FreeswitchChannels } from "@/lib/platform-types";
import { FreeswitchView } from "./freeswitch-view";
export default async function FreeswitchPage() {
const session = await requireSession();
const result = await apiFetch<EslResult<FreeswitchChannels>>("/platform/freeswitch/channels", session.accessToken);
return <FreeswitchView result={result} />;
}

View File

@@ -0,0 +1,43 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import type { EslResult, FreeswitchNodes } from "@/lib/platform-types";
import { EslUnavailableNotice } from "../esl-notice";
export function NodesView({ result }: { result: EslResult<FreeswitchNodes> }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">Nodes</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Esta implantação tem <span className="font-medium text-foreground">1 único node FreeSWITCH</span>{" "}
(container único, sem clustering multi-node) se um dia existir mais de um, esta tela vira uma lista.
</p>
</div>
{!result.ok ? (
<EslUnavailableNotice error={result.error} />
) : (
<>
<Panel className="p-5">
<div className="flex items-center justify-between">
<PanelHeader title="freeswitch-1" description="Único node desta implantação" />
<Pill tone="accent">Online</Pill>
</div>
</Panel>
<Panel>
<PanelHeader title="status" />
<pre className="max-h-96 overflow-auto whitespace-pre-wrap px-5 py-4 font-mono text-xs text-muted-foreground">
{result.data.status}
</pre>
</Panel>
<Panel>
<PanelHeader title="Gateways (troncos conectados)" />
<pre className="max-h-96 overflow-auto px-5 py-4 font-mono text-xs text-muted-foreground">
{JSON.stringify(result.data.gateways, null, 2)}
</pre>
</Panel>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { EslResult, FreeswitchNodes } from "@/lib/platform-types";
import { NodesView } from "./nodes-view";
export default async function NodesPage() {
const session = await requireSession();
const result = await apiFetch<EslResult<FreeswitchNodes>>("/platform/freeswitch/nodes", session.accessToken);
return <NodesView result={result} />;
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { EslResult, FreeswitchProfiles } from "@/lib/platform-types";
import { SipProfilesView } from "./sip-profiles-view";
export default async function SipProfilesPage() {
const session = await requireSession();
const result = await apiFetch<EslResult<FreeswitchProfiles>>("/platform/freeswitch/profiles", session.accessToken);
return <SipProfilesView result={result} />;
}

View File

@@ -0,0 +1,36 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import type { EslResult, FreeswitchProfiles } from "@/lib/platform-types";
import { EslUnavailableNotice } from "../esl-notice";
export function SipProfilesView({ result }: { result: EslResult<FreeswitchProfiles> }) {
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">SIP Profiles</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Saída bruta de <code className="font-mono text-xs">sofia status</code> (perfis internal/external e estado
de cada um) e os endpoints SIP registrados agora.
</p>
</div>
{!result.ok ? (
<EslUnavailableNotice error={result.error} />
) : (
<>
<Panel>
<PanelHeader title="sofia status" />
<pre className="max-h-96 overflow-auto whitespace-pre-wrap px-5 py-4 font-mono text-xs text-muted-foreground">
{result.data.sofiaStatus}
</pre>
</Panel>
<Panel>
<PanelHeader title="Registros SIP ativos" />
<pre className="max-h-96 overflow-auto px-5 py-4 font-mono text-xs text-muted-foreground">
{JSON.stringify(result.data.registrations, null, 2)}
</pre>
</Panel>
</>
)}
</div>
);
}