merge: G5 canonical KPIs (Util média + Em empréstimo) + usePoolsUsageAggregate (gap #5 closed)

This commit is contained in:
diegosouzapw
2026-05-28 14:23:20 -03:00
6 changed files with 365 additions and 7 deletions

View File

@@ -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<Connection[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [plans, setPlans] = useState<Record<string, PlanInfo>>({});
const [sideLoading, setSideLoading] = useState(true);
const [, setSideLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<QuotaPool | null>(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 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label={t("kpiActivePools")} value={String(stats.activePools)} />
<StatCard label={t("kpiKeysAllocated")} value={String(stats.allocations)} />
<StatCard label={t("kpiKeysAllocated")} value={String(stats.keysAllocated)} />
<StatCard
label={t("kpiProvidersWithQuota")}
value={sideLoading ? "…" : String(connections.length)}
label={t("kpiAvgUtilization")}
value={`${Math.round(stats.avgUtilization)}%`}
tone={stats.avgUtilization > 80 ? "red" : stats.avgUtilization > 50 ? "amber" : "green"}
/>
<StatCard
label={t("kpiBorrowingNow")}
value={String(stats.borrowingNow)}
tone={stats.borrowingNow > 0 ? "amber" : undefined}
/>
<StatCard label="Pools" value={String(stats.activePools)} />
</div>
{/* Pool list */}

View File

@@ -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<PoolsUsageAggregate>({
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;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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</);
});
});

View File

@@ -0,0 +1,215 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Mock fetch globally ────────────────────────────────────────────────────
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
// ── Lazy import after mocks ────────────────────────────────────────────────
const { usePoolsUsageAggregate } = await import(
"../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate"
);
// ── Helpers ───────────────────────────────────────────────────────────────
type AggregateState = {
avgUtilizationPercent: number;
borrowingKeyCount: number;
loading: boolean;
error: string | null;
};
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | 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(
<TestComponent
pools={pools}
onState={(s) => {
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();
});
});