mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
merge: G5 canonical KPIs (Util média + Em empréstimo) + usePoolsUsageAggregate (gap #5 closed)
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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</);
|
||||
});
|
||||
});
|
||||
|
||||
215
tests/unit/ui/use-pools-usage-aggregate.test.tsx
Normal file
215
tests/unit/ui/use-pools-usage-aggregate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user