diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx index 1042bcfc43..38cbaf2876 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx @@ -8,6 +8,7 @@ import type { QuotaPool, PoolAllocation } from "@/lib/quota/dimensions"; import { usePools } from "./hooks/usePools"; import { usePoolUsage } from "./hooks/usePoolUsage"; import { useLocalStoragePoolMigration } from "./hooks/useLocalStoragePoolMigration"; +import { usePoolsUsageAggregate } from "./hooks/usePoolsUsageAggregate"; import QuotaConceptCard from "./components/QuotaConceptCard"; import PoolCard from "./components/PoolCard"; import CreatePoolModal from "./components/CreatePoolModal"; @@ -119,7 +120,7 @@ export default function QuotaSharePageClient() { const [connections, setConnections] = useState([]); const [apiKeys, setApiKeys] = useState([]); const [plans, setPlans] = useState>({}); - const [sideLoading, setSideLoading] = useState(true); + const [, setSideLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -189,12 +190,16 @@ export default function QuotaSharePageClient() { [connections] ); + const aggregate = usePoolsUsageAggregate(pools); + const stats = useMemo( () => ({ activePools: pools.length, - allocations: pools.reduce((s, p) => s + p.allocations.length, 0), + keysAllocated: pools.reduce((s, p) => s + p.allocations.length, 0), + avgUtilization: aggregate.avgUtilizationPercent, + borrowingNow: aggregate.borrowingKeyCount, }), - [pools] + [pools, aggregate] ); // ── Mutations ───────────────────────────────────────────────────────────── @@ -257,12 +262,17 @@ export default function QuotaSharePageClient() { {/* Stats */}
- + 80 ? "red" : stats.avgUtilization > 50 ? "amber" : "green"} + /> + 0 ? "amber" : undefined} /> -
{/* Pool list */} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts new file mode 100644 index 0000000000..545016466e --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { QuotaPool } from "@/lib/quota/dimensions"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; + +export interface PoolsUsageAggregate { + avgUtilizationPercent: number; // 0-100 + borrowingKeyCount: number; + loading: boolean; + error: string | null; +} + +const POLL_MS = 15_000; + +export function usePoolsUsageAggregate(pools: QuotaPool[]): PoolsUsageAggregate { + const [state, setState] = useState({ + avgUtilizationPercent: 0, + borrowingKeyCount: 0, + loading: true, + error: null, + }); + + useEffect(() => { + let mounted = true; + const ids = pools.map((p) => p.id); + if (ids.length === 0) { + setState({ avgUtilizationPercent: 0, borrowingKeyCount: 0, loading: false, error: null }); + return; + } + + const fetchAll = async () => { + try { + const snapshots = await Promise.all( + ids.map((id) => fetch(`/api/quota/pools/${id}/usage`).then((r) => (r.ok ? r.json() : null))) + ); + if (!mounted) return; + const valid = snapshots.filter((s): s is { usage: PoolUsageSnapshot } => s !== null && !!s.usage); + let totalUtil = 0; + let utilCount = 0; + let borrowing = 0; + for (const { usage } of valid) { + for (const dim of usage.dimensions) { + if (dim.limit > 0) { + totalUtil += (dim.consumedTotal / dim.limit) * 100; + utilCount += 1; + } + for (const key of dim.perKey) { + if (key.borrowing) borrowing += 1; + } + } + } + setState({ + avgUtilizationPercent: utilCount > 0 ? totalUtil / utilCount : 0, + borrowingKeyCount: borrowing, + loading: false, + error: null, + }); + } catch (err) { + if (mounted) { + setState((s) => ({ ...s, loading: false, error: err instanceof Error ? err.message : "fetch failed" })); + } + } + }; + + void fetchAll(); + const interval = setInterval(fetchAll, POLL_MS); + return () => { + mounted = false; + clearInterval(interval); + }; + }, [pools.map((p) => p.id).join(",")]); // eslint-disable-line react-hooks/exhaustive-deps + + return state; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3b35a14572..b44ff6c028 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7256,6 +7256,8 @@ "betaDescription": "Configuration is saved in localStorage (not yet persisted on the server). Per-request cap enforcement is not yet wired into the proxy pipeline. This screen lets you design and visualize the quota split; real enforcement will land in a future iteration with DB persistence and upstream call interception.", "kpiActivePools": "Active pools", "kpiKeysAllocated": "Keys allocated", + "kpiAvgUtilization": "Avg utilization", + "kpiBorrowingNow": "Borrowing now", "kpiAvgUnallocated": "Avg unallocated", "kpiProvidersWithQuota": "Providers w/ quota", "emptyTitle": "No pools configured", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index a71cb938ed..2341b3494d 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -7246,6 +7246,8 @@ "betaDescription": "A configuração é salva em localStorage (ainda não persistida no servidor). A aplicação dos caps por request ainda não está conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota; a aplicação real virá em uma próxima iteração com persistência no banco e interceptação na chamada upstream.", "kpiActivePools": "Pools ativos", "kpiKeysAllocated": "Keys alocadas", + "kpiAvgUtilization": "Util média", + "kpiBorrowingNow": "Em empréstimo agora", "kpiAvgUnallocated": "Média não alocada", "kpiProvidersWithQuota": "Providers c/ cota", "emptyTitle": "Nenhum pool configurado", diff --git a/tests/unit/ui/quota-share-page.test.tsx b/tests/unit/ui/quota-share-page.test.tsx index 7340d6d63d..cd6799e173 100644 --- a/tests/unit/ui/quota-share-page.test.tsx +++ b/tests/unit/ui/quota-share-page.test.tsx @@ -76,6 +76,19 @@ vi.mock( () => ({ useLocalStoragePoolMigration: mockMigration }) ); +// ── usePoolsUsageAggregate mock ──────────────────────────────────────────── +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate", + () => ({ + usePoolsUsageAggregate: () => ({ + avgUtilizationPercent: 42, + borrowingKeyCount: 3, + loading: false, + error: null, + }), + }) +); + // ── fetch stub ───────────────────────────────────────────────────────────── vi.stubGlobal( "fetch", @@ -166,4 +179,45 @@ describe("QuotaSharePageClient", { timeout: 15000 }, () => { expect(document.body.innerHTML).not.toContain("localStorage"); expect(document.body.innerHTML).not.toContain("betaPreviewLabel"); }); + + // ── KPI cards (Gap #5) ──────────────────────────────────────────────────── + + it("renders the 4 canonical KPI stat cards: kpiActivePools, kpiKeysAllocated, kpiAvgUtilization, kpiBorrowingNow", async () => { + await renderComponent(); + await waitFor(() => document.body.innerHTML.includes("kpiActivePools")); + const html = document.body.innerHTML; + // i18n is stubbed to return key-as-label, so we check for the key strings + expect(html).toContain("kpiActivePools"); + expect(html).toContain("kpiKeysAllocated"); + expect(html).toContain("kpiAvgUtilization"); + expect(html).toContain("kpiBorrowingNow"); + }); + + it("shows stats.activePools value in the kpiActivePools card (2 mock pools)", async () => { + await renderComponent(); + await waitFor(() => document.body.innerHTML.includes("kpiActivePools")); + const html = document.body.innerHTML; + // MOCK_POOLS has 2 pools → activePools = 2 + expect(html).toContain("2"); + }); + + it("shows borrowingKeyCount (3) from mocked usePoolsUsageAggregate in kpiBorrowingNow", async () => { + await renderComponent(); + await waitFor(() => document.body.innerHTML.includes("kpiBorrowingNow")); + const html = document.body.innerHTML; + // usePoolsUsageAggregate mock returns borrowingKeyCount=3 + expect(html).toContain("3"); + }); + + it("does NOT render the duplicate Pools StatCard or kpiProvidersWithQuota", async () => { + await renderComponent(); + await waitFor(() => document.body.innerHTML.includes("kpiActivePools")); + const html = document.body.innerHTML; + // Duplicate 'Pools' literal label must be absent + // (kpiActivePools key may appear, but raw text "Pools" as standalone label must not) + expect(html).not.toContain("kpiProvidersWithQuota"); + // The old duplicate StatCard used the literal string "Pools" — ensure it is gone + // We check that the string ">Pools<" does not appear (it was a text node, not a key) + expect(html).not.toMatch(/>Pools | null = null; +// Mutable ref accessible from outside React render — updated via useEffect to avoid the +// react-hooks/globals lint rule that bans direct assignment inside render bodies. +let capturedState: AggregateState | null = null; + +function TestComponent({ + pools, + onState, +}: { + pools: { id: string; allocations: unknown[] }[]; + onState: (s: AggregateState) => void; +}) { + const state = usePoolsUsageAggregate(pools as any); + // Use useEffect to capture state without triggering react-hooks/globals + const { useEffect } = React; + useEffect(() => { + onState(state); + }); + return null; +} + +async function renderHook(pools: { id: string; allocations: unknown[] }[]) { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + capturedState = null; + await act(async () => { + root = createRoot(container!); + root.render( + { + capturedState = s; + }} + /> + ); + }); +} + +async function waitFor(fn: () => boolean, timeout = 3000) { + const start = Date.now(); + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe("usePoolsUsageAggregate", { timeout: 15000 }, () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedState = null; + }); + + afterEach(() => { + if (root && container) { + act(() => root!.unmount()); + } + container?.remove(); + container = null; + root = null; + capturedState = null; + }); + + // ── Scenario 1: no pools ──────────────────────────────────────────────── + it("returns zeros immediately and does NOT call fetch when pools is empty", async () => { + await renderHook([]); + // Should be synchronously settled after act + expect(capturedState).not.toBeNull(); + expect(capturedState!.avgUtilizationPercent).toBe(0); + expect(capturedState!.borrowingKeyCount).toBe(0); + expect(capturedState!.loading).toBe(false); + expect(capturedState!.error).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // ── Scenario 2: 2 pools, fetch resolves with snapshots ────────────────── + it("aggregates avgUtilizationPercent and borrowingKeyCount across 2 pools", async () => { + // Pool 1: consumedTotal=50, limit=100 → util=50%; 1 borrowing key + const pool1Response = { + usage: { + dimensions: [ + { + limit: 100, + consumedTotal: 50, + perKey: [{ borrowing: true }, { borrowing: false }], + }, + ], + }, + }; + // Pool 2: consumedTotal=75, limit=100 → util=75%; 2 borrowing keys + const pool2Response = { + usage: { + dimensions: [ + { + limit: 100, + consumedTotal: 75, + perKey: [{ borrowing: true }, { borrowing: true }], + }, + ], + }, + }; + + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(pool1Response) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(pool2Response) }); + + const pools = [ + { id: "pool_1", allocations: [] }, + { id: "pool_2", allocations: [] }, + ]; + + await renderHook(pools); + + await act(async () => { + await waitFor(() => capturedState?.loading === false); + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith("/api/quota/pools/pool_1/usage"); + expect(mockFetch).toHaveBeenCalledWith("/api/quota/pools/pool_2/usage"); + + // avgUtilizationPercent = (50 + 75) / 2 = 62.5 + expect(capturedState!.avgUtilizationPercent).toBeCloseTo(62.5); + // borrowingKeyCount = 1 + 2 = 3 + expect(capturedState!.borrowingKeyCount).toBe(3); + expect(capturedState!.loading).toBe(false); + expect(capturedState!.error).toBeNull(); + }); + + // ── Scenario 3: fetch failure → fail-soft ─────────────────────────────── + it("sets error and loading=false on fetch failure (fail-soft, does not throw)", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const pools = [{ id: "pool_fail", allocations: [] }]; + + await renderHook(pools); + + await act(async () => { + await waitFor(() => capturedState?.loading === false); + }); + + expect(capturedState!.loading).toBe(false); + expect(capturedState!.error).toBeTruthy(); + expect(capturedState!.error).toContain("Network error"); + expect(capturedState!.avgUtilizationPercent).toBe(0); + expect(capturedState!.borrowingKeyCount).toBe(0); + }); + + // ── Scenario 4: dimensions with limit === 0 are skipped ───────────────── + it("ignores dimensions with limit === 0 to avoid division by zero", async () => { + const poolResponse = { + usage: { + dimensions: [ + { + // limit=0 — must be skipped (no util contribution) + limit: 0, + consumedTotal: 999, + perKey: [{ borrowing: true }], + }, + { + // limit=100, consumed=40 → util=40%; no borrowing + limit: 100, + consumedTotal: 40, + perKey: [{ borrowing: false }], + }, + ], + }, + }; + + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(poolResponse) }); + + const pools = [{ id: "pool_zero", allocations: [] }]; + + await renderHook(pools); + + await act(async () => { + await waitFor(() => capturedState?.loading === false); + }); + + // Only the valid dimension (limit=100) contributes to util + expect(capturedState!.avgUtilizationPercent).toBeCloseTo(40); + // borrowing from the limit=0 dimension still counts (perKey loop is independent) + expect(capturedState!.borrowingKeyCount).toBe(1); + expect(capturedState!.loading).toBe(false); + expect(capturedState!.error).toBeNull(); + }); +});