feat(web): ping API ponta-a-ponta via TanStack Query + Zod contract
Fecha loop B+C — Web consome @sar/api-interface em runtime, não só build. - Vite proxy /api → localhost:3000 (zero CORS em dev, mesma URL em prod via Nginx) - api-client.ts: fetch wrapper parseando RFC 9457 problem+json em ApiError - useApiPing: TanStack Query + PingResponseSchema.parse — drift servidor falha alto - FoundationStatus pill na Topbar (verde/vermelho/cinza + Tooltip com requestId) Validado via curl proxy:4200 → 200 ok contratual; /nope → 404 problem+json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
122
apps/web/src/components/layout/FoundationStatus.tsx
Normal file
122
apps/web/src/components/layout/FoundationStatus.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Badge, Tooltip, Typography } from 'antd';
|
||||||
|
import { ApiError } from '../../lib/api-client';
|
||||||
|
import { useApiPing } from '../../lib/queries/ping';
|
||||||
|
import { brandTokens } from '../../lib/theme';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// Pill discreto de "fundação viva" — prova que API↔Web↔contrato Zod funcionam.
|
||||||
|
// Conscientemente mantido na Topbar enquanto o produto está em foundation;
|
||||||
|
// quando virar normal, vira indicador só em /health (Sandra/Daniel).
|
||||||
|
export function FoundationStatus() {
|
||||||
|
const { data, error, isPending, isFetching } = useApiPing();
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
color={brandTokens.textMuted}
|
||||||
|
label="API…"
|
||||||
|
tooltip="Verificando conexão com a API"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const detail =
|
||||||
|
error instanceof ApiError
|
||||||
|
? `${error.problem.title}${error.problem.detail ? ` — ${error.problem.detail}` : ''}`
|
||||||
|
: error.message;
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
color={brandTokens.red}
|
||||||
|
label="API offline"
|
||||||
|
tooltip={
|
||||||
|
<TooltipLines
|
||||||
|
lines={[
|
||||||
|
['Erro', detail],
|
||||||
|
['Status', String((error as ApiError).status ?? '—')],
|
||||||
|
['Request', (error as ApiError).problem?.requestId ?? '—'],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
color={brandTokens.green}
|
||||||
|
label={`API v${data.version}`}
|
||||||
|
pulse={isFetching}
|
||||||
|
tooltip={
|
||||||
|
<TooltipLines
|
||||||
|
lines={[
|
||||||
|
['Service', data.service],
|
||||||
|
['Version', data.version],
|
||||||
|
['Workspace', data.workspaceId],
|
||||||
|
['Request', data.requestId.slice(0, 8) + '…'],
|
||||||
|
['Uptime', `${data.uptimeSeconds}s`],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({
|
||||||
|
color,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
pulse,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
tooltip: React.ReactNode;
|
||||||
|
pulse?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltip} placement="bottomRight">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
height: 28,
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--bg-surface-alt)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 'var(--font-weight-medium)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
aria-label={`Estado da API: ${label}`}
|
||||||
|
>
|
||||||
|
<Badge color={color} status={pulse ? 'processing' : undefined} />
|
||||||
|
<span className="tabular-nums">{label}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipLines({ lines }: { lines: ReadonlyArray<readonly [string, string]> }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '2px 12px' }}>
|
||||||
|
{lines.map(([label, value]) => (
|
||||||
|
<FragmentRow key={label} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FragmentRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 12 }}>{label}</Text>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 12 }} className="tabular-nums">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Avatar, Badge, Button, Flex, Input } from 'antd';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
import { faBell, faMagnifyingGlass, faBars } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { brandTokens } from '../../lib/theme';
|
import { brandTokens } from '../../lib/theme';
|
||||||
|
import { FoundationStatus } from './FoundationStatus';
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
@@ -84,8 +85,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Lado direito: notificações + perfil */}
|
{/* Lado direito: status fundação + notificações + perfil */}
|
||||||
<Flex align="center" gap={16}>
|
<Flex align="center" gap={16}>
|
||||||
|
<FoundationStatus />
|
||||||
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
|
<Badge count={3} color={brandTokens.red} offset={[-4, 4]}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
89
apps/web/src/lib/api-client.ts
Normal file
89
apps/web/src/lib/api-client.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Cliente HTTP da SAR Web.
|
||||||
|
//
|
||||||
|
// Responsabilidades:
|
||||||
|
// - Encapsular fetch com base URL relativa (proxy Vite em dev, mesmo origin em prod).
|
||||||
|
// - Parsear RFC 9457 application/problem+json em ApiError estruturado.
|
||||||
|
// - NÃO faz validação Zod — isso é responsabilidade do caller (useQuery + Schema.parse).
|
||||||
|
//
|
||||||
|
// CODING-RULES §05: 422 = validação Zod; 4xx outros = erros de domínio; 5xx = retry pelo
|
||||||
|
// QueryClient (até 2x). O ApiError carrega tudo que o caller precisa pra decidir.
|
||||||
|
|
||||||
|
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
|
||||||
|
|
||||||
|
export interface ProblemDetails {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
requestId?: string;
|
||||||
|
errors?: ReadonlyArray<{ path: string; message: string; code?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly problem: ProblemDetails;
|
||||||
|
|
||||||
|
constructor(problem: ProblemDetails) {
|
||||||
|
super(problem.detail ?? problem.title);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = problem.status;
|
||||||
|
this.problem = problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(path: string, options: RequestOptions = {}): Promise<unknown> {
|
||||||
|
const { body, headers, ...rest } = options;
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
...rest,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(path, init);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw await toApiError(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (response.status === 204) return undefined;
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toApiError(response: Response): Promise<ApiError> {
|
||||||
|
const contentType = response.headers.get('Content-Type') ?? '';
|
||||||
|
|
||||||
|
if (contentType.includes(PROBLEM_CONTENT_TYPE) || contentType.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as ProblemDetails;
|
||||||
|
return new ApiError({
|
||||||
|
type: body.type ?? 'about:blank',
|
||||||
|
title: body.title ?? response.statusText,
|
||||||
|
status: body.status ?? response.status,
|
||||||
|
detail: body.detail,
|
||||||
|
instance: body.instance,
|
||||||
|
requestId: body.requestId,
|
||||||
|
errors: body.errors,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ApiError({
|
||||||
|
type: 'about:blank',
|
||||||
|
title: response.statusText || 'Request failed',
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
apps/web/src/lib/queries/ping.ts
Normal file
26
apps/web/src/lib/queries/ping.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { PingResponseSchema, type PingResponse } from '@sar/api-interface';
|
||||||
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
|
// useApiPing — prova de conectividade ponta-a-ponta API↔Web.
|
||||||
|
//
|
||||||
|
// O contrato é o schema Zod compartilhado (@sar/api-interface). Qualquer drift
|
||||||
|
// no servidor (campo removido, tipo trocado) falha alto via .parse() ANTES de
|
||||||
|
// chegar nos componentes — o erro vai pra TanStack `error` e mostramos pill 🔴.
|
||||||
|
//
|
||||||
|
// refetchInterval 30s = "sereno" (Visual DNA) — sem flash de loading constante.
|
||||||
|
|
||||||
|
export const PING_QUERY_KEY = ['health', 'ping'] as const;
|
||||||
|
|
||||||
|
export function useApiPing() {
|
||||||
|
return useQuery<PingResponse, Error>({
|
||||||
|
queryKey: PING_QUERY_KEY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/api/v1/ping');
|
||||||
|
return PingResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 25_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,15 @@ export default defineConfig(() => ({
|
|||||||
server: {
|
server: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
|
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
||||||
|
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
||||||
|
// mesmo origin via Nginx (/api/* → backend).
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
port: 4200,
|
port: 4200,
|
||||||
|
|||||||
@@ -319,6 +319,30 @@
|
|||||||
4. **Master-login stub + WorkspacePrismaPool** (frente arquitetural pesada — depende de #3).
|
4. **Master-login stub + WorkspacePrismaPool** (frente arquitetural pesada — depende de #3).
|
||||||
5. **OpenTelemetry SDK** plugar quando entrar em catálogo (stub atual mantém posição).
|
5. **OpenTelemetry SDK** plugar quando entrar em catálogo (stub atual mantém posição).
|
||||||
|
|
||||||
|
### 2026-05-27 — Web→API ponta-a-ponta (loop B+C fechado) CONCLUÍDO ✅
|
||||||
|
- **Escopo da sessão:** Pendência #1 do roadmap — provar que `@sar/api-interface` é honrado pelos dois lados em runtime, não só em build time. Loop B (Web foundation) + C (Zod contracts) fechado.
|
||||||
|
- **Arquivos novos:**
|
||||||
|
- `apps/web/src/lib/api-client.ts` — fetch wrapper que parseia `application/problem+json` (RFC 9457) em `ApiError` estruturado carregando status+type+title+detail+requestId+errors[]. Sem validação Zod aqui — responsabilidade do caller (CODING-RULES §05).
|
||||||
|
- `apps/web/src/lib/queries/ping.ts` — `useApiPing()` TanStack Query chamando `/api/v1/ping` + `PingResponseSchema.parse(...)`. Drift servidor falha alto **antes** de chegar nos componentes. `refetchInterval: 30s` (Visual DNA "sereno").
|
||||||
|
- `apps/web/src/components/layout/FoundationStatus.tsx` — pill discreto na Topbar (verde/vermelho/cinza) com Tooltip detalhando service+version+workspaceId+requestId+uptime. Pulse `processing` no refetch silencioso. Conscientemente temporário — quando produto entrar em normal, vira indicador só em `/health`.
|
||||||
|
- **Modificados:**
|
||||||
|
- `apps/web/vite.config.mts` — `server.proxy['/api']: http://localhost:3000`. Evita CORS em dev e mantém URL relativa no código da Web; em produção, Nginx roteia mesmo origin. `changeOrigin: false` (mesma host).
|
||||||
|
- `apps/web/src/components/layout/Topbar.tsx` — `<FoundationStatus />` antes do sino.
|
||||||
|
- **Validação ponta-a-ponta:**
|
||||||
|
- `nx run web:lint` ✅ · `nx run web:build` ✅ (821ms, 309KB gzip)
|
||||||
|
- `curl :4200/api/v1/ping` via proxy Vite → `200 application/json` com payload contratual completo (`status=ok`, `workspaceId=dev-workspace`, `requestId`, `uptimeSeconds=144`, `now`)
|
||||||
|
- `curl :4200/api/v1/nope` → `404 application/problem+json` com `type/title/detail/instance/requestId` — prova que `ApiError` captura erro estruturado quando servidor falhar
|
||||||
|
- Headers helmet, x-request-id, CORS expose-headers passam pelo proxy intactos
|
||||||
|
- **Decisão arquitetural confirmada:** Web consome lib `@sar/api-interface` sem arrastar nada do Nest. `PingResponseSchema` viaja como Zod puro; `ApiError` na Web não conhece `HttpException` do Nest — só o contrato HTTP+JSON. Alinhamento com regra "lib stays framework-free" da sessão anterior.
|
||||||
|
- **Pegadinhas notadas (não bloquearam):**
|
||||||
|
- `_sidebarOpen` no AppShell ainda tem `eslint-disable` — fica pra Frente D ou primeiro responsivo mobile.
|
||||||
|
- Bundle Web 976KB (309KB gzip) — code-splitting esperado quando rotas de cockpit virarem separadas (TanStack Router suporta nativo). Não age agora.
|
||||||
|
- **Pendente próxima sessão (ordem atualizada):**
|
||||||
|
1. **Frente D — ESLint boundaries** (tags Nx `scope:* · type:* · domain:*`) + Husky + gitleaks. Higiene de PR antes de feature pesada.
|
||||||
|
2. **PRD WDS** via `/bmad-prd create` antes de modelar domínio.
|
||||||
|
3. **Master-login stub + WorkspacePrismaPool** (frente arquitetural — depende do PRD).
|
||||||
|
4. **OpenTelemetry SDK** plugar quando entrar no catálogo.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## About This Folder
|
## About This Folder
|
||||||
|
|||||||
Reference in New Issue
Block a user