diff --git a/TODO.md b/TODO.md index 30fe467..19a9e7f 100644 --- a/TODO.md +++ b/TODO.md @@ -1591,6 +1591,32 @@ Usuários (agente.md secao 169) os números reais e corretos dos dois tenants de teste (Acme com 50% de troncos usados, Beta Corp com zero) +## PHASE 44 — Platform > Infraestrutura > FreeSWITCH/SIP Profiles/Nodes +(agente.md secao 168-169) +- [x] `GET /platform/freeswitch/channels|profiles|nodes` — introspecção + ESL de verdade (`show channels`/`show calls`/`sofia status`/ + `show registrations`/`status`/`show gateways`), reaproveitando os + métodos do `FreeSwitchTelephonyProvider` já verificados manualmente + contra o FreeSWITCH real (`packages/telephony`). Mesmo padrão de + conexão avulsa do `PlatformHealthController` (connect → comando → + disconnect, sem manter ESL permanente só pra tela de admin) +- [x] Nunca deixa a indisponibilidade do ESL virar 500/503 pro cliente — + devolve `{ ok: false, error }` (200), mesma filosofia do `timed()` + do health check. Necessário aqui: nesta VM `apps/api` roda fora do + Docker e a porta 8021 é deliberadamente não publicada no host + (agente.md secao 184, docker-compose.yml) — as 3 telas SEMPRE vão + mostrar essa mensagem explicada aqui, mesmo com o endpoint 100% + funcional (confirmado indiretamente: `b2bcall-fs-events`, que fala + ESL de dentro da rede Docker, está com heartbeat ativo o tempo + todo — o FreeSWITCH/ESL está saudável, só inalcançável do host) +- [x] "Nodes" mostra explicitamente "1 node" (container único, sem + clustering) em vez de fingir uma lista — mesma decisão já registrada + pro dashboard de plataforma +- [x] Testado ponta a ponta: os 3 endpoints via curl (200 com + `{ok:false, error:"Timeout conectando..."}`) e as 3 telas via + Puppeteer, mostrando a explicação por que falha aqui (mesmo texto + já usado em Infraestrutura > Saúde) + --- ## Riscos conhecidos diff --git a/apps/api/src/platform/platform-freeswitch.controller.ts b/apps/api/src/platform/platform-freeswitch.controller.ts new file mode 100644 index 0000000..e5e13da --- /dev/null +++ b/apps/api/src/platform/platform-freeswitch.controller.ts @@ -0,0 +1,75 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth"; +import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; + +/** + * Infraestrutura > FreeSWITCH/SIP Profiles/Nodes (agente.md secao 168-169) + * — introspecção ESL real, mesmo padrão de conexão avulsa (connect → + * comando → disconnect) já usado em `PlatformHealthController`. Igual ao + * health check, nunca deixa a indisponibilidade do ESL virar 500/503 pro + * cliente — devolve `{ ok: false, error }` (mesma filosofia do `timed()` + * de lá), porque nesta VM o Event Socket (8021) é deliberadamente não + * publicado no host (agente.md secao 184) e `apps/api` roda fora do + * Docker: essas 3 telas SEMPRE vão mostrar essa mensagem aqui, mesmo + * endpoint funcionando corretamente em produção (mesma network do + * FreeSWITCH). "Nodes" é sempre 1 node nesta implantação (container + * único, sem clustering) — honesto em vez de fingir uma lista. + */ +async function withEsl(fn: (provider: FreeSwitchTelephonyProvider) => Promise): Promise<{ ok: true; data: T } | { ok: false; error: string }> { + const host = process.env.ESL_HOST; + const port = Number(process.env.ESL_PORT ?? 8021); + const password = process.env.ESL_PASSWORD; + if (!host || !password) return { ok: false, error: "ESL nao configurado (ESL_HOST/ESL_PASSWORD ausentes)" }; + + const provider = new FreeSwitchTelephonyProvider({ host, port, password }); + try { + provider.connect(); + const connected = await provider.waitUntilConnected(2500); + if (!connected) return { ok: false, error: "Timeout conectando no ESL do FreeSWITCH" }; + return { ok: true, data: await fn(provider) }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Erro desconhecido falando com o ESL" }; + } finally { + await provider.disconnect(); + } +} + +function requirePlatform(user: AccessTokenClaims): Promise { + return isPlatformUser(user.sub); +} + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/freeswitch") +export class PlatformFreeswitchController { + @RequirePermission("freeswitch.view") + @Get("channels") + async channels(@CurrentUser() user: AccessTokenClaims) { + if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver o FreeSWITCH"); + return withEsl(async (p) => ({ channels: await p.getChannels(), calls: await p.getCalls() })); + } + + @RequirePermission("freeswitch.view") + @Get("profiles") + async profiles(@CurrentUser() user: AccessTokenClaims) { + if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver SIP profiles"); + return withEsl(async (p) => ({ + sofiaStatus: await p.runApi("sofia status"), + registrations: await p.getRegistrations(), + })); + } + + @RequirePermission("freeswitch.view") + @Get("nodes") + async nodes(@CurrentUser() user: AccessTokenClaims) { + if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver os nodes"); + return withEsl(async (p) => ({ + nodeCount: 1, + status: await p.runApi("status"), + gateways: await p.getGateways(), + })); + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index 60f4a89..4061ccb 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -5,6 +5,7 @@ import { PlatformAuditController } from "./platform-audit.controller"; import { PlatformHealthController } from "./platform-health.controller"; import { PlatformRolesController } from "./platform-roles.controller"; import { PlatformQuotasController } from "./platform-quotas.controller"; +import { PlatformFreeswitchController } from "./platform-freeswitch.controller"; @Module({ controllers: [ @@ -14,6 +15,7 @@ import { PlatformQuotasController } from "./platform-quotas.controller"; PlatformHealthController, PlatformRolesController, PlatformQuotasController, + PlatformFreeswitchController, ], }) export class PlatformModule {} diff --git a/apps/frontend/.impeccable/review/fs-freeswitch-desktop.png b/apps/frontend/.impeccable/review/fs-freeswitch-desktop.png new file mode 100644 index 0000000..29c6e5e Binary files /dev/null and b/apps/frontend/.impeccable/review/fs-freeswitch-desktop.png differ diff --git a/apps/frontend/.impeccable/review/fs-nodes-desktop.png b/apps/frontend/.impeccable/review/fs-nodes-desktop.png new file mode 100644 index 0000000..4001b35 Binary files /dev/null and b/apps/frontend/.impeccable/review/fs-nodes-desktop.png differ diff --git a/apps/frontend/.impeccable/review/fs-sip-profiles-desktop.png b/apps/frontend/.impeccable/review/fs-sip-profiles-desktop.png new file mode 100644 index 0000000..475495a Binary files /dev/null and b/apps/frontend/.impeccable/review/fs-sip-profiles-desktop.png differ diff --git a/apps/frontend/src/app/platform/infraestrutura/esl-notice.tsx b/apps/frontend/src/app/platform/infraestrutura/esl-notice.tsx new file mode 100644 index 0000000..232e706 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/esl-notice.tsx @@ -0,0 +1,22 @@ +import { Panel, PanelHeader } from "@/components/ui/panel"; + +export function EslUnavailableNotice({ error }: { error: string }) { + return ( + + +
+

+ Erro retornado: {error} +

+

+ apps/api roda direto no host desta VM, fora do Docker; a porta + do Event Socket (8021) do FreeSWITCH é deliberadamente não publicada no host (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{" "} + apps/api roda na mesma rede Docker do FreeSWITCH, esta tela + mostra os dados reais. +

+
+
+ ); +} diff --git a/apps/frontend/src/app/platform/infraestrutura/freeswitch/freeswitch-view.tsx b/apps/frontend/src/app/platform/infraestrutura/freeswitch/freeswitch-view.tsx new file mode 100644 index 0000000..53a57ea --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/freeswitch/freeswitch-view.tsx @@ -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 }) { + return ( +
+
+

FreeSWITCH

+

+ Canais e chamadas ativas agora, direto do ESL (show channels/ + show calls) — leitura ao vivo, não histórico. +

+
+ + {!result.ok ? ( + + ) : ( + <> + + +
+              {JSON.stringify(result.data.channels, null, 2)}
+            
+
+ + +
+              {JSON.stringify(result.data.calls, null, 2)}
+            
+
+ + )} +
+ ); +} diff --git a/apps/frontend/src/app/platform/infraestrutura/freeswitch/page.tsx b/apps/frontend/src/app/platform/infraestrutura/freeswitch/page.tsx new file mode 100644 index 0000000..a62a0fc --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/freeswitch/page.tsx @@ -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>("/platform/freeswitch/channels", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/infraestrutura/nodes/nodes-view.tsx b/apps/frontend/src/app/platform/infraestrutura/nodes/nodes-view.tsx new file mode 100644 index 0000000..42778b5 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/nodes/nodes-view.tsx @@ -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 }) { + return ( +
+
+

Nodes

+

+ Esta implantação tem 1 único node FreeSWITCH{" "} + (container único, sem clustering multi-node) — se um dia existir mais de um, esta tela vira uma lista. +

+
+ + {!result.ok ? ( + + ) : ( + <> + +
+ + Online +
+
+ + +
+              {result.data.status}
+            
+
+ + +
+              {JSON.stringify(result.data.gateways, null, 2)}
+            
+
+ + )} +
+ ); +} diff --git a/apps/frontend/src/app/platform/infraestrutura/nodes/page.tsx b/apps/frontend/src/app/platform/infraestrutura/nodes/page.tsx new file mode 100644 index 0000000..1b03065 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/nodes/page.tsx @@ -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>("/platform/freeswitch/nodes", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/infraestrutura/sip-profiles/page.tsx b/apps/frontend/src/app/platform/infraestrutura/sip-profiles/page.tsx new file mode 100644 index 0000000..eb17356 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/sip-profiles/page.tsx @@ -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>("/platform/freeswitch/profiles", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/infraestrutura/sip-profiles/sip-profiles-view.tsx b/apps/frontend/src/app/platform/infraestrutura/sip-profiles/sip-profiles-view.tsx new file mode 100644 index 0000000..ec52ac7 --- /dev/null +++ b/apps/frontend/src/app/platform/infraestrutura/sip-profiles/sip-profiles-view.tsx @@ -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 }) { + return ( +
+
+

SIP Profiles

+

+ Saída bruta de sofia status (perfis internal/external e estado + de cada um) e os endpoints SIP registrados agora. +

+
+ + {!result.ok ? ( + + ) : ( + <> + + +
+              {result.data.sofiaStatus}
+            
+
+ + +
+              {JSON.stringify(result.data.registrations, null, 2)}
+            
+
+ + )} +
+ ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 3c8082b..1da627f 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -68,9 +68,21 @@ export const PLATFORM_NAV: NavSection[] = [ label: "Infraestrutura", icon: ServerCog, children: [ - { label: "FreeSWITCH" }, - { label: "SIP Profiles" }, - { label: "Nodes" }, + { + label: "FreeSWITCH", + href: "/platform/infraestrutura/freeswitch", + description: "Canais e chamadas ativas agora, direto do ESL", + }, + { + label: "SIP Profiles", + href: "/platform/infraestrutura/sip-profiles", + description: "sofia status e registros SIP ativos", + }, + { + label: "Nodes", + href: "/platform/infraestrutura/nodes", + description: "Nodes FreeSWITCH desta implantação (hoje: 1)", + }, { label: "Saúde", href: "/platform/infraestrutura/saude", diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index e02c32c..2f68f1b 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -83,6 +83,24 @@ export interface PlatformHealth { checkedAt: string; } +export type EslResult = { ok: true; data: T } | { ok: false; error: string }; + +export interface FreeswitchChannels { + channels: unknown; + calls: unknown; +} + +export interface FreeswitchProfiles { + sofiaStatus: string; + registrations: unknown; +} + +export interface FreeswitchNodes { + nodeCount: number; + status: string; + gateways: unknown; +} + export interface QuotaItem { key: string; label: string;