diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx index 2228f32ed7..1042bcfc43 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx @@ -1,421 +1,48 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import Card from "@/shared/components/Card"; -import { Button, Modal } from "@/shared/components"; -import ProviderIcon from "@/shared/components/ProviderIcon"; +import { Button } from "@/shared/components"; +import type { QuotaPool, PoolAllocation } from "@/lib/quota/dimensions"; + +import { usePools } from "./hooks/usePools"; +import { usePoolUsage } from "./hooks/usePoolUsage"; +import { useLocalStoragePoolMigration } from "./hooks/useLocalStoragePoolMigration"; +import QuotaConceptCard from "./components/QuotaConceptCard"; +import PoolCard from "./components/PoolCard"; +import CreatePoolModal from "./components/CreatePoolModal"; +import EditAllocationsModal from "./components/EditAllocationsModal"; // ──────────────────────────────────────────────────────────────────────────── -// Types +// Local types (display layer only) // ──────────────────────────────────────────────────────────────────────────── -type Allocation = { apiKeyId: string; percent: number }; -type PoolPolicy = "hard" | "soft" | "burst"; - -type QuotaPool = { - id: string; - connectionId: string; // provider connection id (account) - provider: string; - accountLabel: string; - window: string; // e.g. "session", "weekly", "credits" - windowLabel: string; - totalQuota: number; // numeric total (tokens / sessions / dollars / requests) - unit: string; // "tokens" | "sessions" | "USD" | "requests" | "credits" - resetIso?: string | null; - policy: PoolPolicy; - allocations: Allocation[]; - createdAt: number; -}; - -type Connection = { +interface Connection { id: string; provider: string; name?: string; displayName?: string; email?: string; - authType?: string; -}; - -type ApiKey = { id: string; name?: string }; - -type CachedProviderLimit = { - fetchedAt?: string; - plan?: string; - quotas?: Record; -}; - -type CachedAllResponse = { - caches?: Record; -}; - -const LS_POOLS = "omniroute:quota-share:pools"; - -// Side-effecting helpers kept at module scope so React's purity lint does -// not flag callers — they only ever run inside event handlers. -function generatePoolId(): string { - return `pool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; -} -function nowMs(): number { - return Date.now(); } -// Palette for allocation slices in donut + bars -const SLICE_PALETTE = [ - "#a78bfa", // violet - "#60a5fa", // blue - "#34d399", // emerald - "#fbbf24", // amber - "#f87171", // red - "#22d3ee", // cyan - "#f472b6", // pink - "#94a3b8", // slate -]; - -// ──────────────────────────────────────────────────────────────────────────── -// Helpers -// ──────────────────────────────────────────────────────────────────────────── - -function loadPools(): QuotaPool[] { - if (typeof window === "undefined") return []; - try { - const raw = localStorage.getItem(LS_POOLS); - return raw ? JSON.parse(raw) : []; - } catch { - return []; - } +interface ApiKey { + id: string; + name?: string; } -function persistPools(pools: QuotaPool[]) { - try { - localStorage.setItem(LS_POOLS, JSON.stringify(pools)); - } catch { - /* ignore */ - } -} - -function shortId(value: string, max = 12) { - return value.length > max ? `${value.slice(0, max)}…` : value; -} - -function fmtNumber(n: number, unit?: string): string { - if (unit === "USD") return `$${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; - if (!Number.isFinite(n)) return "—"; - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return `${n}`; -} - -function fmtCountdown(iso?: string | null): string { - if (!iso) return "—"; - try { - const diff = new Date(iso).getTime() - Date.now(); - if (diff <= 0) return "now"; - const h = Math.floor(diff / 3_600_000); - const m = Math.floor((diff % 3_600_000) / 60_000); - if (h >= 24) { - const d = Math.floor(h / 24); - return `${d}d ${h % 24}h`; - } - return `${h}h ${m}m`; - } catch { - return "—"; - } -} - -// Extract candidate pool sources from a provider's cached quota response. -// Each quota window (session/weekly/credits) becomes a potential pool. -function extractPoolSources( - conn: Connection, - cached: CachedProviderLimit | undefined -): Array<{ - window: string; - windowLabel: string; - totalQuota: number; +interface PlanDimension { unit: string; - resetIso?: string | null; -}> { - if (!cached?.quotas) return []; - const out: Array<{ - window: string; - windowLabel: string; - totalQuota: number; - unit: string; - resetIso?: string | null; - }> = []; - for (const [key, q] of Object.entries(cached.quotas)) { - if (!q || typeof q !== "object") continue; - const isCredits = (q as any).isCredits === true || /^credits/i.test(key); - const total = Number((q as any).total ?? 0); - const remaining = Number((q as any).remaining ?? 0); - const totalQuota = isCredits ? Math.max(total, remaining) : total; - if (totalQuota <= 0 && !isCredits) continue; - const windowLabel = - (q as any).displayName || key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); - const unit = isCredits - ? ((q as any).currency as string) || "USD" - : key === "session" || key === "weekly" - ? "tokens" - : "requests"; - out.push({ - window: key, - windowLabel, - totalQuota: totalQuota || remaining, - unit, - resetIso: (q as any).resetAt || null, - }); - } - return out; + window: string; + limit: number; } -// Donut SVG renderer — slices from allocations array + unfilled gap if <100% -function Donut({ - size = 140, - thickness = 22, - slices, -}: { - size?: number; - thickness?: number; - slices: Array<{ percent: number; color: string; label?: string }>; -}) { - const r = size / 2 - thickness / 2; - const c = 2 * Math.PI * r; - const cx = size / 2; - const cy = size / 2; - // Precompute cumulative offsets immutably so we can map slices without - // mutating across renders. - const lens = slices.map((s) => Math.max(0, Math.min(s.percent, 100)) / 100); - const offsets = lens.reduce((acc, len, idx) => { - acc.push(idx === 0 ? 0 : (acc[idx - 1] as number) + lens[idx - 1]); - return acc; - }, []); - return ( - - - {slices.map((s, i) => { - const dash = lens[i] * c; - const offset = -offsets[i] * c; - return ( - - ); - })} - - ); +interface PlanInfo { + dimensions: PlanDimension[]; + source: "auto" | "manual"; } // ──────────────────────────────────────────────────────────────────────────── -// Component -// ──────────────────────────────────────────────────────────────────────────── - -export default function QuotaSharePageClient() { - const t = useTranslations("quotaShare"); - const [connections, setConnections] = useState([]); - const [caches, setCaches] = useState>({}); - const [apiKeys, setApiKeys] = useState([]); - const [pools, setPools] = useState([]); - const [loading, setLoading] = useState(true); - const [createOpen, setCreateOpen] = useState(false); - const [editing, setEditing] = useState(null); - - // ── Fetch ──────────────────────────────────────────────────────────────── - - const loadAll = useCallback(async () => { - setLoading(true); - try { - const [connsRes, cacheRes, keysRes] = await Promise.all([ - fetch("/api/providers/client"), - fetch("/api/usage/provider-limits"), - fetch("/api/keys"), - ]); - const connsData = connsRes.ok ? await connsRes.json() : null; - const conns: Connection[] = Array.isArray(connsData?.connections) - ? connsData.connections - : []; - const cacheData: CachedAllResponse | null = cacheRes.ok ? await cacheRes.json() : null; - const keysData = keysRes.ok ? await keysRes.json() : null; - const keys: ApiKey[] = Array.isArray(keysData) ? keysData : keysData?.keys || []; - setConnections(conns); - setCaches(cacheData?.caches || {}); - setApiKeys(keys); - } catch (err) { - console.error("[QuotaShare] load failed", err); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadAll(); - setPools(loadPools()); - }, [loadAll]); - - // ── Mutations ──────────────────────────────────────────────────────────── - - const savePools = useCallback((next: QuotaPool[]) => { - setPools(next); - persistPools(next); - }, []); - - const upsertPool = useCallback( - (pool: QuotaPool) => { - const idx = pools.findIndex((p) => p.id === pool.id); - const next = [...pools]; - if (idx >= 0) next[idx] = pool; - else next.push(pool); - savePools(next); - }, - [pools, savePools] - ); - - const removePool = useCallback( - (id: string) => { - if (!confirm(t("removeConfirm"))) return; - savePools(pools.filter((p) => p.id !== id)); - }, - [pools, savePools] - ); - - // ── Derived ────────────────────────────────────────────────────────────── - - const stats = useMemo(() => { - let allocations = 0; - let atCap = 0; - let uncappedPct = 0; - for (const p of pools) { - allocations += p.allocations.length; - const totalPct = p.allocations.reduce((s, a) => s + a.percent, 0); - if (totalPct < 100) uncappedPct += 100 - totalPct; - // Simulated "at cap" — without backend tracking, mark pools with >=1 - // allocation summing to 100% as "fully utilized config" (proxy metric). - if (totalPct >= 100 && p.allocations.length > 0) atCap += 0; // see disclaimer - } - return { - activePools: pools.length, - allocations, - atCap, - uncapped: pools.length > 0 ? Math.round(uncappedPct / pools.length) : 0, - }; - }, [pools]); - - // ── Render ─────────────────────────────────────────────────────────────── - - return ( -
- {/* Header */} -
-
-

- pie_chart - {t("title")} -

-

{t("description")}

-
- -
- - {/* Beta disclaimer */} -
- science -
- {t("betaPreviewLabel")} {t("betaConfigSavedPrefix")}{" "} - localStorage {t("betaConfigSavedSuffix")} -
-
- - {/* Stats */} -
- - - 0 ? "amber" : "green"} - /> - -
- - {loading ? ( -
- {t("loading")} -
- ) : pools.length === 0 ? ( -
- pie_chart -

{t("emptyTitle")}

-

{t("emptyDescription")}

- -
- ) : ( -
- {pools.map((pool) => ( - setEditing(pool)} - onRemove={() => removePool(pool.id)} - onPolicyChange={(policy) => upsertPool({ ...pool, policy })} - /> - ))} -
- )} - - {createOpen && ( - setCreateOpen(false)} - onCreate={(pool) => { - upsertPool(pool); - setCreateOpen(false); - }} - /> - )} - - {editing && ( - setEditing(null)} - onSave={(allocations) => { - upsertPool({ ...editing, allocations }); - setEditing(null); - }} - /> - )} -
- ); -} - -// ──────────────────────────────────────────────────────────────────────────── -// Sub-components +// Stat card helper // ──────────────────────────────────────────────────────────────────────────── function StatCard({ @@ -445,478 +72,249 @@ function StatCard({ ); } -function PoolCard({ +// ──────────────────────────────────────────────────────────────────────────── +// Per-pool wrapper that fetches usage +// ──────────────────────────────────────────────────────────────────────────── + +function PoolCardWithUsage({ pool, - cached, - apiKeys, + keyLabels, + connectionLabel, + provider, onEdit, onRemove, - onPolicyChange, }: { pool: QuotaPool; - cached?: CachedProviderLimit; - apiKeys: ApiKey[]; + keyLabels: Record; + connectionLabel: string; + provider: string; onEdit: () => void; onRemove: () => void; - onPolicyChange: (policy: PoolPolicy) => void; }) { + const { usage } = usePoolUsage(pool.id); + return ( + + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Main component +// ──────────────────────────────────────────────────────────────────────────── + +export default function QuotaSharePageClient() { const t = useTranslations("quotaShare"); - // Refresh totalQuota from latest cache if available - const live = cached?.quotas?.[pool.window]; - const liveTotal = live ? Number((live as any).total || 0) : 0; - const liveRemaining = live ? Number((live as any).remaining || 0) : 0; - const total = liveTotal > 0 ? liveTotal : pool.totalQuota; - const used = liveTotal > 0 ? liveTotal - liveRemaining : 0; - const usedPct = total > 0 ? Math.min((used / total) * 100, 100) : 0; - const resetIso = (live as any)?.resetAt || pool.resetIso; - const totalAllocated = pool.allocations.reduce((s, a) => s + a.percent, 0); - const unallocated = Math.max(0, 100 - totalAllocated); + const { pools, loading, mutate } = usePools(); - const slices = [ - ...pool.allocations.map((a, i) => ({ - percent: a.percent, - color: SLICE_PALETTE[i % SLICE_PALETTE.length], - label: a.apiKeyId, - })), - ]; - if (unallocated > 0) { - slices.push({ percent: unallocated, color: "rgba(255,255,255,0.10)", label: "free" }); - } + // LS → DB migration hook (B22) — runs once, idempotent + useLocalStoragePoolMigration({ pools, mutate }); - const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || id.slice(0, 12) + "…"; + const [connections, setConnections] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [plans, setPlans] = useState>({}); + const [sideLoading, setSideLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); + + // ── Fetch side data once on mount ───────────────────────────────────────── + + useMemo(() => { + setSideLoading(true); + Promise.all([ + fetch("/api/providers/client") + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + fetch("/api/keys") + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + fetch("/api/quota/plans") + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + ]) + .then(([connsData, keysData, plansData]) => { + const conns: Connection[] = Array.isArray(connsData?.connections) + ? connsData.connections + : []; + const keys: ApiKey[] = Array.isArray(keysData) ? keysData : keysData?.keys || []; + setConnections(conns); + setApiKeys(keys); + + if (Array.isArray(plansData)) { + const planMap: Record = {}; + for (const p of plansData as Array<{ + connectionId: string; + dimensions: PlanDimension[]; + source: "auto" | "manual"; + }>) { + if (p.connectionId) planMap[p.connectionId] = { dimensions: p.dimensions, source: p.source }; + } + setPlans(planMap); + } + }) + .catch(() => { + // fail open — side data not critical + }) + .finally(() => { + setSideLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── Derived ────────────────────────────────────────────────────────────── + + const keyLabels = useMemo(() => { + const map: Record = {}; + for (const k of apiKeys) map[k.id] = k.name || k.id.slice(0, 12) + "…"; + return map; + }, [apiKeys]); + + const connLabel = useCallback( + (connectionId: string) => { + const conn = connections.find((c) => c.id === connectionId); + if (!conn) return connectionId.slice(0, 12); + return conn.name || conn.email || conn.displayName || conn.id.slice(0, 12); + }, + [connections] + ); + + const connProvider = useCallback( + (connectionId: string) => connections.find((c) => c.id === connectionId)?.provider || "unknown", + [connections] + ); + + const stats = useMemo( + () => ({ + activePools: pools.length, + allocations: pools.reduce((s, p) => s + p.allocations.length, 0), + }), + [pools] + ); + + // ── Mutations ───────────────────────────────────────────────────────────── + + const handleCreate = useCallback( + async (poolData: Omit) => { + await fetch("/api/quota/pools", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(poolData), + }); + await mutate(); + }, + [mutate] + ); + + const handleSaveAllocations = useCallback( + async (pool: QuotaPool, allocations: PoolAllocation[]) => { + await fetch(`/api/quota/pools/${pool.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allocations }), + }); + await mutate(); + }, + [mutate] + ); + + const handleRemovePool = useCallback( + async (id: string) => { + if (!confirm(t("removeConfirm"))) return; + await fetch(`/api/quota/pools/${id}`, { method: "DELETE" }); + await mutate(); + }, + [mutate, t] + ); + + // ── Render ──────────────────────────────────────────────────────────────── return ( - -
-
-
- -
-
-
- {pool.provider} · {pool.accountLabel} -
-
- {pool.windowLabel} · {t("resetIn")} {fmtCountdown(resetIso)} · {t("quotaTotal")}{" "} - {fmtNumber(total, pool.unit)} {pool.unit === "USD" ? "" : pool.unit} -
-
-
-
- -
-
- -
- {/* Donut */} -
-
- -
-
- {t("pool")} -
-
- {Math.round(usedPct)}% -
-
{t("used")}
-
-
-
-
-
-
-
- {fmtNumber(used, pool.unit)} / {fmtNumber(total, pool.unit)} -
-
-
- - {/* Allocations */} +
+ {/* Header */} +
-
-

- {t("allocationsCount", { count: pool.allocations.length })} -

- 100 - ? "text-red-400" - : "text-amber-400" - }`} - > - {t("allocatedFree", { allocated: totalAllocated, free: unallocated })} - -
- - {pool.allocations.length === 0 ? ( -
- {t("noAllocations")} -
- ) : ( -
- {pool.allocations.map((a, i) => { - const cap = total > 0 ? (total * a.percent) / 100 : 0; - const color = SLICE_PALETTE[i % SLICE_PALETTE.length]; - return ( -
- - - {keyLabel(a.apiKeyId)} - - - {a.percent}% - - - {t("capLabel", { value: fmtNumber(cap, pool.unit) })} - - {t("notTrackedYet")} -
- ); - })} -
- )} - -
-
- - {t("policyLabel")} - - {(["hard", "soft", "burst"] as PoolPolicy[]).map((p) => ( - - ))} -
-
- -
-
+

+ pie_chart + {t("title")} +

+

{t("description")}

+
- - ); -} -function CreatePoolModal({ - connections, - caches, - existingPools, - onClose, - onCreate, -}: { - connections: Connection[]; - caches: Record; - existingPools: QuotaPool[]; - onClose: () => void; - onCreate: (pool: QuotaPool) => void; -}) { - const t = useTranslations("quotaShare"); - const [connectionId, setConnectionId] = useState(""); - const [window, setWindow] = useState(""); - - // Connections that have at least one quota window - const eligibleConnections = useMemo(() => { - return connections.filter((c) => { - const cached = caches[c.id]; - return extractPoolSources(c, cached).length > 0; - }); - }, [connections, caches]); - - const selectedConn = connections.find((c) => c.id === connectionId); - const windowSources = selectedConn ? extractPoolSources(selectedConn, caches[connectionId]) : []; - const selectedWindow = windowSources.find((w) => w.window === window); - - // Already used (connection+window) pairs to avoid duplicates - const usedPairs = new Set(existingPools.map((p) => `${p.connectionId}:${p.window}`)); - - const handleCreate = () => { - if (!selectedConn || !selectedWindow) return; - if (usedPairs.has(`${connectionId}:${window}`)) { - alert(t("duplicatePoolError")); - return; - } - const accountLabel = - selectedConn.name || - selectedConn.email || - selectedConn.displayName || - selectedConn.id.slice(0, 12); - const pool: QuotaPool = { - id: generatePoolId(), - connectionId, - provider: selectedConn.provider, - accountLabel, - window, - windowLabel: selectedWindow.windowLabel, - totalQuota: selectedWindow.totalQuota, - unit: selectedWindow.unit, - resetIso: selectedWindow.resetIso || null, - policy: "hard", - allocations: [], - createdAt: nowMs(), - }; - onCreate(pool); - }; - - return ( - -
-
- - - {eligibleConnections.length === 0 && ( -

{t("noEligibleConnections")}

- )} -
- - {connectionId && ( -
- - -
- )} - - {selectedWindow && ( -
-
- {selectedWindow.windowLabel} ·{" "} - {fmtNumber(selectedWindow.totalQuota, selectedWindow.unit)} {selectedWindow.unit} -
-
- {t("windowReset")}:{" "} - {selectedWindow.resetIso ? new Date(selectedWindow.resetIso).toLocaleString() : "—"} -
-
- )} - -
- - -
+ {/* Concept card */} + + + {/* Stats */} +
+ + + +
- - ); -} - -function EditAllocationsModal({ - pool, - apiKeys, - onClose, - onSave, -}: { - pool: QuotaPool; - apiKeys: ApiKey[]; - onClose: () => void; - onSave: (allocations: Allocation[]) => void; -}) { - const t = useTranslations("quotaShare"); - const [drafts, setDrafts] = useState(pool.allocations); - const total = drafts.reduce((s, a) => s + (Number.isFinite(a.percent) ? a.percent : 0), 0); - const availableKeys = apiKeys.filter((k) => !drafts.some((a) => a.apiKeyId === k.id)); - - const addKey = (id: string) => { - setDrafts((prev) => [...prev, { apiKeyId: id, percent: 0 }]); - }; - - const updatePercent = (id: string, value: number) => { - setDrafts((prev) => - prev.map((a) => - a.apiKeyId === id ? { ...a, percent: Math.max(0, Math.min(100, value)) } : a - ) - ); - }; - - const removeKey = (id: string) => { - setDrafts((prev) => prev.filter((a) => a.apiKeyId !== id)); - }; - - const equalSplit = () => { - if (drafts.length === 0) return; - const each = Math.floor(100 / drafts.length); - const remainder = 100 - each * drafts.length; - setDrafts((prev) => prev.map((a, i) => ({ ...a, percent: each + (i < remainder ? 1 : 0) }))); - }; - - const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || shortId(id); - - return ( - -
-
- {t("pool")}:{" "} - - {pool.provider} / {pool.accountLabel} · {pool.windowLabel} - -
- {t("quotaTotal")}: {fmtNumber(pool.totalQuota, pool.unit)} {pool.unit} + {/* Pool list */} + {loading ? ( +
+ {t("loading")}
- - {drafts.length === 0 ? ( -
- {t("noKeysAdded")} -
- ) : ( -
- {drafts.map((a, i) => { - const color = SLICE_PALETTE[i % SLICE_PALETTE.length]; - const cap = (pool.totalQuota * a.percent) / 100; - return ( -
- - {keyLabel(a.apiKeyId)} - updatePercent(a.apiKeyId, Number(e.target.value))} - className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums" - /> - - {t("capLabel", { value: fmtNumber(cap, pool.unit) })} - - -
- ); - })} -
- )} - -
- 100 ? "text-red-400" : "text-amber-400" - }`} - > - {t("totalLabel", { percent: total })} {total > 100 && t("totalExceeded")} - -
- {availableKeys.length > 0 && ( - - )} - -
-
- -
- -
-
- + ) : ( +
+ {pools.map((pool) => ( + setEditing(pool)} + onRemove={() => void handleRemovePool(pool.id)} + /> + ))} +
+ )} + + {/* Modals */} + {createOpen && ( + setCreateOpen(false)} + onCreate={handleCreate} + /> + )} + + {editing && ( + setEditing(null)} + onSave={(allocations) => handleSaveAllocations(editing, allocations)} + /> + )} +
); } diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx new file mode 100644 index 0000000000..14ad070b1c --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import type { PoolAllocation } from "@/lib/quota/dimensions"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; + +interface AllocationTableProps { + allocations: PoolAllocation[]; + usage: PoolUsageSnapshot | null; + /** Map from apiKeyId to display name */ + keyLabels: Record; +} + +const SLICE_PALETTE = [ + "#a78bfa", + "#60a5fa", + "#34d399", + "#fbbf24", + "#f87171", + "#22d3ee", + "#f472b6", + "#94a3b8", +]; + +export default function AllocationTable({ allocations, usage, keyLabels }: AllocationTableProps) { + const t = useTranslations("quotaShare"); + + if (allocations.length === 0) { + return ( +
+ {t("noAllocations")} +
+ ); + } + + // Build per-key consumption lookup from first dimension (primary) + const primaryDim = usage?.dimensions?.[0]; + + return ( +
+ + + + + + + + + + + + {allocations.map((alloc, i) => { + const color = SLICE_PALETTE[i % SLICE_PALETTE.length]; + const label = keyLabels[alloc.apiKeyId] || alloc.apiKeyId.slice(0, 12) + "…"; + + const perKeyData = primaryDim?.perKey?.find((k) => k.apiKeyId === alloc.apiKeyId); + const consumed = perKeyData?.consumed ?? null; + const fairShare = perKeyData?.fairShare ?? null; + const deficit = perKeyData !== undefined ? perKeyData.deficit : null; + const borrowing = perKeyData?.borrowing ?? false; + + return ( + + + + + + + + ); + })} + +
API KeyWeight{t("realConsumedColumn")}{t("deficitColumn")}Policy
+
+ + {label} + {borrowing && ( + + {t("borrowingIndicator")} + + )} +
+
+ {alloc.weight}% + + {consumed !== null ? consumed.toLocaleString() : "—"} + + {deficit !== null ? ( + 0 ? "text-red-400" : deficit < 0 ? "text-emerald-400" : "text-text-muted" + } + > + {deficit > 0 ? "+" : ""}{deficit.toLocaleString()} + + ) : ( + + )} + {fairShare !== null && ( + + (fair: {fairShare.toLocaleString()}) + + )} + + + {alloc.policy} + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart.tsx new file mode 100644 index 0000000000..a223f4e6a9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; + +// Lazy-load recharts — do NOT import at module level (B28) +const RechartsLineChart = dynamic( + () => import("recharts").then((m) => ({ default: m.LineChart })), + { ssr: false } +); +const RechartsLine = dynamic(() => import("recharts").then((m) => ({ default: m.Line })), { + ssr: false, +}); +const RechartsXAxis = dynamic(() => import("recharts").then((m) => ({ default: m.XAxis })), { + ssr: false, +}); +const RechartsYAxis = dynamic(() => import("recharts").then((m) => ({ default: m.YAxis })), { + ssr: false, +}); +const RechartsTooltip = dynamic(() => import("recharts").then((m) => ({ default: m.Tooltip })), { + ssr: false, +}); +const RechartsResponsiveContainer = dynamic( + () => import("recharts").then((m) => ({ default: m.ResponsiveContainer })), + { ssr: false } +); + +export interface BurnRateChartProps { + usage: PoolUsageSnapshot | null; +} + +export default function BurnRateChart({ usage }: BurnRateChartProps) { + const t = useTranslations("quotaShare"); + // Capture mount time once — avoids impure Date.now() call on every render + const [nowMs] = useState(() => Date.now()); + + const burnRate = usage?.burnRate; + const hasData = burnRate && burnRate.tokensPerSecond > 0; + + if (!hasData) { + return ( +
+

{t("burnRateTitle")} — no data yet

+
+ ); + } + + const { tokensPerSecond, timeToExhaustionMs } = burnRate; + + // Build a simple 6-point projection line + const pointCount = 6; + const intervalMs = timeToExhaustionMs ? timeToExhaustionMs / pointCount : 60_000 * 60; + + const primaryDim = usage?.dimensions?.[0]; + const currentConsumed = primaryDim?.consumedTotal ?? 0; + const limit = primaryDim?.limit ?? 0; + + const data = Array.from({ length: pointCount + 1 }, (_, i) => { + const t2 = nowMs + i * intervalMs; + const projected = Math.min(currentConsumed + tokensPerSecond * ((i * intervalMs) / 1000), limit); + return { + time: new Date(t2).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }), + consumed: Math.round(projected), + }; + }); + + const exhaustionLabel = timeToExhaustionMs + ? `${t("burnRateExhaustsIn")} ${fmtDuration(timeToExhaustionMs)}` + : null; + + return ( +
+
+ {t("burnRateTitle")} + {exhaustionLabel && {exhaustionLabel}} +
+
+ + + + + + + + +
+
+ ); +} + +function fmtDuration(ms: number): string { + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + if (h >= 24) { + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; + } + return `${h}h ${m}m`; +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/CreatePoolModal.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/CreatePoolModal.tsx new file mode 100644 index 0000000000..d9be376aba --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/CreatePoolModal.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Modal } from "@/shared/components"; +import type { QuotaPool, Policy, QuotaDimension } from "@/lib/quota/dimensions"; + +interface Connection { + id: string; + provider: string; + name?: string; + displayName?: string; + email?: string; +} + +interface PlanInfo { + dimensions: QuotaDimension[]; + source: "auto" | "manual"; +} + +interface CreatePoolModalProps { + connections: Connection[]; + plans: Record; + existingPools: QuotaPool[]; + onClose: () => void; + onCreate: (pool: Omit) => Promise; +} + +export default function CreatePoolModal({ + connections, + plans, + existingPools, + onClose, + onCreate, +}: CreatePoolModalProps) { + const t = useTranslations("quotaShare"); + const [connectionId, setConnectionId] = useState(""); + const [name, setName] = useState(""); + const [defaultPolicy, setDefaultPolicy] = useState("hard"); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const usedConnectionIds = useMemo( + () => new Set(existingPools.map((p) => p.connectionId)), + [existingPools] + ); + + const selectedConn = connections.find((c) => c.id === connectionId); + const planInfo = connectionId ? plans[connectionId] : undefined; + const hasPlan = planInfo && planInfo.dimensions.length > 0; + + const connLabel = (c: Connection) => + `${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`; + + const handleCreate = async () => { + if (!selectedConn) return; + if (usedConnectionIds.has(connectionId)) { + setError(t("duplicatePoolError")); + return; + } + const poolName = name.trim() || connLabel(selectedConn); + setSaving(true); + setError(null); + try { + await onCreate({ + connectionId, + name: poolName, + allocations: [], + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create pool"); + } finally { + setSaving(false); + } + }; + + return ( + +
+ {/* Connection selector */} +
+ + + {connections.length === 0 && ( +

{t("noEligibleConnections")}

+ )} +
+ + {/* Pool name */} + {connectionId && ( +
+ + setName(e.target.value)} + placeholder={selectedConn ? connLabel(selectedConn) : "My quota pool"} + className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm" + /> +
+ )} + + {/* Default policy */} + {connectionId && ( +
+ +
+ {(["hard", "soft", "burst"] as Policy[]).map((p) => ( + + ))} +
+
+ )} + + {/* Plan info */} + {connectionId && hasPlan && ( +
+
+ {t("multiDimensionLabel")} ({planInfo.source}) +
+ {planInfo.dimensions.map((d, i) => ( +
+ {d.unit} / {d.window}: {d.limit} +
+ ))} +
+ )} + + {/* Cap absolute notice */} + {connectionId && ( +
+ {t("policyCapAbsoluteLabel")}:{" "} + {t("policyCapAbsolutePlaceholder")} +
+ )} + + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar.tsx new file mode 100644 index 0000000000..8a7071985f --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import type { QuotaDimension } from "@/lib/quota/dimensions"; + +interface DimensionBarProps { + dimension: QuotaDimension; + consumedTotal: number; + /** ISO string for next reset, or null */ + resetAt?: string | null; +} + +function fmtCountdown(ms: number): string { + if (ms <= 0) return "now"; + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + if (h >= 24) { + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; + } + return `${h}h ${m}m`; +} + +export default function DimensionBar({ dimension, consumedTotal, resetAt }: DimensionBarProps) { + const t = useTranslations("quotaShare"); + // Capture mount time once — avoids impure Date.now() call on every render + const [now] = useState(() => Date.now()); + const usedPct = + dimension.limit > 0 ? Math.min((consumedTotal / dimension.limit) * 100, 100) : 0; + + const barColor = + usedPct >= 90 + ? "bg-red-500" + : usedPct >= 70 + ? "bg-amber-400" + : "bg-primary"; + + const resetMs = resetAt ? new Date(resetAt).getTime() - now : null; + const countdown = resetMs !== null && resetMs > 0 ? fmtCountdown(resetMs) : null; + + return ( +
+
+ + {dimension.unit} / {dimension.window} + + = 90 ? "#f87171" : usedPct >= 70 ? "#fbbf24" : undefined }}> + {Math.round(usedPct)}% + +
+
+
+
+ {countdown && ( +
+ {t("dimensionResetIn")} {countdown} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx new file mode 100644 index 0000000000..875d1c66d0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/EditAllocationsModal.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Modal } from "@/shared/components"; +import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions"; + +interface ApiKey { + id: string; + name?: string; +} + +interface EditAllocationsModalProps { + pool: QuotaPool; + apiKeys: ApiKey[]; + onClose: () => void; + onSave: (allocations: PoolAllocation[]) => Promise; +} + +function shortId(id: string, max = 12) { + return id.length > max ? `${id.slice(0, max)}…` : id; +} + +const SLICE_PALETTE = [ + "#a78bfa", + "#60a5fa", + "#34d399", + "#fbbf24", + "#f87171", + "#22d3ee", + "#f472b6", + "#94a3b8", +]; + +export default function EditAllocationsModal({ + pool, + apiKeys, + onClose, + onSave, +}: EditAllocationsModalProps) { + const t = useTranslations("quotaShare"); + const [drafts, setDrafts] = useState(pool.allocations); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const totalWeight = drafts.reduce( + (s, a) => s + (Number.isFinite(a.weight) ? a.weight : 0), + 0 + ); + + const availableKeys = apiKeys.filter((k) => !drafts.some((a) => a.apiKeyId === k.id)); + + const keyLabel = (id: string) => apiKeys.find((k) => k.id === id)?.name || shortId(id); + + const addKey = (id: string) => { + setDrafts((prev) => [...prev, { apiKeyId: id, weight: 0, policy: "hard" }]); + }; + + const updateWeight = (id: string, value: number) => { + setDrafts((prev) => + prev.map((a) => + a.apiKeyId === id ? { ...a, weight: Math.max(0, Math.min(100, value)) } : a + ) + ); + }; + + const updatePolicy = (id: string, policy: Policy) => { + setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, policy } : a))); + }; + + const updateCapValue = (id: string, capValue: number | undefined) => { + setDrafts((prev) => prev.map((a) => (a.apiKeyId === id ? { ...a, capValue } : a))); + }; + + const removeKey = (id: string) => { + setDrafts((prev) => prev.filter((a) => a.apiKeyId !== id)); + }; + + const equalSplit = () => { + if (drafts.length === 0) return; + const each = Math.floor(100 / drafts.length); + const remainder = 100 - each * drafts.length; + setDrafts((prev) => prev.map((a, i) => ({ ...a, weight: each + (i < remainder ? 1 : 0) }))); + }; + + const handleSave = async () => { + setSaving(true); + setError(null); + try { + await onSave(drafts); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+ {t("pool")}: {pool.name} +
+ + {drafts.length === 0 ? ( +
+ {t("noKeysAdded")} +
+ ) : ( +
+ {drafts.map((a, i) => { + const color = SLICE_PALETTE[i % SLICE_PALETTE.length]; + return ( +
+ + {keyLabel(a.apiKeyId)} + updateWeight(a.apiKeyId, Number(e.target.value))} + className="px-2 py-1 rounded border border-border bg-bg-base text-sm text-right tabular-nums" + title="Weight %" + /> + {/* Cap absolute */} + + updateCapValue(a.apiKeyId, e.target.value ? Number(e.target.value) : undefined) + } + placeholder={t("policyCapAbsolutePlaceholder")} + className="px-2 py-1 rounded border border-border bg-bg-base text-xs tabular-nums" + title={t("policyCapAbsoluteLabel")} + /> + {/* Policy per key */} + + +
+ ); + })} +
+ )} + +
+ 100 + ? "text-red-400" + : "text-amber-400" + }`} + > + {t("totalLabel", { percent: totalWeight })}{" "} + {totalWeight > 100 && t("totalExceeded")} + +
+ {availableKeys.length > 0 && ( + + )} + +
+
+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx new file mode 100644 index 0000000000..bfa2540299 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import Card from "@/shared/components/Card"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import type { QuotaPool } from "@/lib/quota/dimensions"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; +import DimensionBar from "./DimensionBar"; +import AllocationTable from "./AllocationTable"; +import BurnRateChart from "./BurnRateChart"; + +export interface PoolCardProps { + pool: QuotaPool; + usage: PoolUsageSnapshot | null; + /** Map from apiKeyId to display name */ + keyLabels: Record; + /** Connection display label */ + connectionLabel: string; + /** Provider identifier */ + provider: string; + onEdit: () => void; + onRemove: () => void; +} + +function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" { + if (!usage || usage.dimensions.length === 0) return "green"; + const utilizations = usage.dimensions.map((d) => + d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0 + ); + const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length; + if (avg > 80) return "red"; + if (avg > 50) return "amber"; + return "green"; +} + +const STATUS_ICONS = { + green: { icon: "check_circle", cls: "text-emerald-400" }, + amber: { icon: "warning", cls: "text-amber-400" }, + red: { icon: "error", cls: "text-red-400" }, +}; + +export default function PoolCard({ + pool, + usage, + keyLabels, + connectionLabel, + provider, + onEdit, + onRemove, +}: PoolCardProps) { + const t = useTranslations("quotaShare"); + const status = computeStatus(usage); + const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status]; + + // Check for plan dimensions from usage + const hasDimensions = usage && usage.dimensions.length > 0; + + return ( + + {/* Header */} +
+
+
+ +
+
+
+ + {statusIcon} + + + {statusIcon} + + + {pool.name} · {connectionLabel} + +
+
+ {t("allocationsCount", { count: pool.allocations.length })} · ID: {pool.id.slice(0, 12)} +
+
+
+
+ + +
+
+ + {/* Dimensions side-by-side */} + {hasDimensions ? ( +
+ {usage.dimensions.map((dim, i) => ( + + ))} +
+ ) : ( +
+ {t("multiDimensionLabel")} — {t("loading")} +
+ )} + + {/* Allocation table */} +
+

+ Allocations +

+ +
+ + {/* Burn rate chart */} + {usage && ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/QuotaConceptCard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/QuotaConceptCard.tsx new file mode 100644 index 0000000000..eadae5ca44 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/QuotaConceptCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import Card from "@/shared/components/Card"; + +export default function QuotaConceptCard() { + const t = useTranslations("quotaShare"); + const [expanded, setExpanded] = useState(false); + + return ( + + + + {expanded && ( +
+

{t("conceptIntro")}

+
+ + + + +
+
+ )} +
+ ); +} + +function ConceptItem({ icon, text }: { icon: string; text: string }) { + return ( +
+ + {icon} + + {text} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration.ts b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration.ts new file mode 100644 index 0000000000..3aa4505a3f --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration.ts @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect } from "react"; +import type { QuotaPool, PoolAllocation, Policy } from "@/lib/quota/dimensions"; + +const LS_KEY = "omniroute:quota-share:pools"; + +// Shape of a legacy localStorage pool (QuotaSharePageClient.tsx old format) +interface LsPool { + id?: string; + connectionId?: string; + provider?: string; + accountLabel?: string; + window?: string; + policy?: string; + allocations?: Array<{ + apiKeyId?: string; + percent?: number; + }>; +} + +interface PoolCreate { + connectionId: string; + name: string; + allocations: Array<{ + apiKeyId: string; + weight: number; + capValue?: number; + capUnit?: string; + policy: Policy; + }>; +} + +export function adaptLsPoolToApiSchema(lsPool: LsPool): PoolCreate { + const connectionId = lsPool.connectionId || ""; + const name = + lsPool.accountLabel || + lsPool.provider || + lsPool.connectionId?.slice(0, 12) || + "Migrated pool"; + const policy: Policy = + lsPool.policy === "soft" || lsPool.policy === "burst" + ? (lsPool.policy as Policy) + : "hard"; + + const allocations: PoolAllocation[] = (lsPool.allocations || []) + .filter((a) => a.apiKeyId) + .map((a) => ({ + apiKeyId: a.apiKeyId as string, + weight: typeof a.percent === "number" ? Math.max(0, Math.min(100, a.percent)) : 0, + policy, + })); + + return { connectionId, name, allocations }; +} + +export interface UseLocalStoragePoolMigrationInput { + pools: QuotaPool[]; + mutate: () => Promise; +} + +export function useLocalStoragePoolMigration({ + pools, + mutate, +}: UseLocalStoragePoolMigrationInput): void { + useEffect(() => { + if (typeof window === "undefined") return; + const raw = window.localStorage.getItem(LS_KEY); + if (!raw) return; + + // Idempotency: if DB already has pools, do not migrate + if (pools.length > 0) { + // Leave localStorage key intact (safety — let user verify before cleanup) + return; + } + + let lsPools: unknown[] = []; + try { + lsPools = JSON.parse(raw) as unknown[]; + } catch { + window.localStorage.removeItem(LS_KEY); + return; + } + + if (!Array.isArray(lsPools) || lsPools.length === 0) { + window.localStorage.removeItem(LS_KEY); + return; + } + + // POST batch — migrate all pools + Promise.all( + lsPools.map((p) => + fetch("/api/quota/pools", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(adaptLsPoolToApiSchema(p as LsPool)), + }).then((r) => r.ok) + ) + ) + .then((results) => { + if (results.every(Boolean)) { + window.localStorage.removeItem(LS_KEY); + void mutate(); + } + }) + .catch(() => { + // fail silent — try again on next load + }); + }, [pools.length, mutate]); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts new file mode 100644 index 0000000000..4a2af4181a --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PoolUsageSnapshot } from "@/lib/quota/types"; + +export interface UsePoolUsageResult { + usage: PoolUsageSnapshot | null; + loading: boolean; + error: string | null; +} + +export function usePoolUsage(poolId: string, pollIntervalMs = 15_000): UsePoolUsageResult { + const [usage, setUsage] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const fetchUsage = useCallback(async () => { + if (!poolId) return; + try { + const res = await fetch(`/api/quota/pools/${poolId}/usage`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = (await res.json()) as PoolUsageSnapshot; + if (!mountedRef.current) return; + setUsage(data); + setError(null); + } catch (err) { + if (!mountedRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load usage"); + } finally { + if (mountedRef.current) setLoading(false); + } + }, [poolId]); + + useEffect(() => { + mountedRef.current = true; + void fetchUsage(); + + const interval = setInterval(() => { + void fetchUsage(); + }, pollIntervalMs); + + return () => { + mountedRef.current = false; + clearInterval(interval); + }; + }, [fetchUsage, pollIntervalMs]); + + return { usage, loading, error }; +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts new file mode 100644 index 0000000000..1278642134 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { QuotaPool } from "@/lib/quota/dimensions"; + +export interface UsePoolsResult { + pools: QuotaPool[]; + loading: boolean; + error: string | null; + mutate: () => Promise; +} + +export function usePools(): UsePoolsResult { + const [pools, setPools] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + + const fetchPools = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/quota/pools"); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data: unknown = await res.json(); + if (!mountedRef.current) return; + const list = Array.isArray(data) + ? (data as QuotaPool[]) + : Array.isArray((data as { pools?: QuotaPool[] }).pools) + ? (data as { pools: QuotaPool[] }).pools + : []; + setPools(list); + } catch (err) { + if (!mountedRef.current) return; + setError(err instanceof Error ? err.message : "Failed to load pools"); + } finally { + if (mountedRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + mountedRef.current = true; + void fetchPools(); + return () => { + mountedRef.current = false; + }; + }, [fetchPools]); + + const mutate = useCallback(async () => { + await fetchPools(); + }, [fetchPools]); + + return { pools, loading, error, mutate }; +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient.tsx new file mode 100644 index 0000000000..25e1051880 --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { knownProviders, getKnownPlan } from "@/lib/quota/planRegistry"; +import type { QuotaDimension, QuotaUnit, QuotaWindow } from "@/lib/quota/dimensions"; + +// ──────────────────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────────────────── + +interface Connection { + id: string; + provider: string; + name?: string; + displayName?: string; + email?: string; +} + +interface ProviderPlanOverride { + connectionId: string; + provider: string; + dimensions: QuotaDimension[]; + source: "auto" | "manual"; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Constants +// ──────────────────────────────────────────────────────────────────────────── + +const UNIT_OPTIONS: QuotaUnit[] = ["percent", "requests", "tokens", "usd"]; +const WINDOW_OPTIONS: QuotaWindow[] = ["5h", "hourly", "daily", "weekly", "monthly"]; + +// ──────────────────────────────────────────────────────────────────────────── +// Component +// ──────────────────────────────────────────────────────────────────────────── + +export default function ProviderPlanConfigClient() { + const t = useTranslations("quotaPlans"); + + const [connections, setConnections] = useState([]); + const [selectedConnectionId, setSelectedConnectionId] = useState(""); + const [overrides, setOverrides] = useState>({}); + const [editDimensions, setEditDimensions] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [reverting, setReverting] = useState(false); + const [error, setError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + // ── Load connections and existing overrides ─────────────────────────────── + + useEffect(() => { + setLoading(true); + Promise.all([ + fetch("/api/providers/client") + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + fetch("/api/quota/plans") + .then((r) => (r.ok ? r.json() : null)) + .catch(() => null), + ]) + .then(([connsData, plansData]) => { + const conns: Connection[] = Array.isArray(connsData?.connections) + ? connsData.connections + : []; + setConnections(conns); + + if (Array.isArray(plansData)) { + const map: Record = {}; + for (const p of plansData as ProviderPlanOverride[]) { + if (p.connectionId) map[p.connectionId] = p; + } + setOverrides(map); + } + }) + .catch(() => { + setError("Failed to load data"); + }) + .finally(() => setLoading(false)); + }, []); + + // ── Derived: selected connection and plan info ──────────────────────────── + + const selectedConn = connections.find((c) => c.id === selectedConnectionId); + const selectedProvider = selectedConn?.provider || ""; + + const existingOverride = selectedConnectionId ? overrides[selectedConnectionId] : undefined; + const catalogPlan = selectedProvider ? getKnownPlan(selectedProvider) : null; + + const detectedSource = existingOverride?.source || (catalogPlan ? "auto" : null); + + const connLabel = (c: Connection) => + `${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`; + + // ── When connection changes, populate edit dimensions ───────────────────── + + useEffect(() => { + if (!selectedConnectionId) { + setEditDimensions([]); + return; + } + // Priority: manual override > catalog + if (existingOverride && existingOverride.source === "manual") { + setEditDimensions(existingOverride.dimensions); + } else if (catalogPlan) { + setEditDimensions([...catalogPlan.dimensions]); + } else { + setEditDimensions([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedConnectionId]); + + // ── Dimension editors ───────────────────────────────────────────────────── + + const addDimension = () => { + setEditDimensions((prev) => [...prev, { unit: "percent", window: "daily", limit: 100 }]); + }; + + const removeDimension = (i: number) => { + setEditDimensions((prev) => prev.filter((_, idx) => idx !== i)); + }; + + const updateDimension = (i: number, patch: Partial) => { + setEditDimensions((prev) => prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d))); + }; + + // ── Save override ───────────────────────────────────────────────────────── + + const handleSaveOverride = useCallback(async () => { + if (!selectedConnectionId) return; + setSaving(true); + setError(null); + setSuccessMsg(null); + try { + const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dimensions: editDimensions }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Refresh overrides + const data = (await res.json()) as ProviderPlanOverride; + setOverrides((prev) => ({ ...prev, [selectedConnectionId]: data })); + setSuccessMsg(t("saveOverrideButton") + " — saved"); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed"); + } finally { + setSaving(false); + } + }, [selectedConnectionId, editDimensions, t]); + + // ── Revert to catalog ───────────────────────────────────────────────────── + + const handleRevertToCatalog = useCallback(async () => { + if (!selectedConnectionId) return; + setReverting(true); + setError(null); + setSuccessMsg(null); + try { + const res = await fetch(`/api/quota/plans/${selectedConnectionId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setOverrides((prev) => { + const next = { ...prev }; + delete next[selectedConnectionId]; + return next; + }); + // Reset edit dims to catalog + if (catalogPlan) setEditDimensions([...catalogPlan.dimensions]); + else setEditDimensions([]); + setSuccessMsg(t("revertToCatalogButton") + " — reverted"); + } catch (err) { + setError(err instanceof Error ? err.message : "Revert failed"); + } finally { + setReverting(false); + } + }, [selectedConnectionId, catalogPlan, t]); + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+

+ fact_check + {t("title")} +

+

{t("description")}

+
+ + {loading ? ( +
Loading…
+ ) : ( +
+ {/* Left: connection selector */} +
+
+ + +
+ + {/* Catalog known plans */} +
+
+ {t("catalogTitle")} +
+

{t("catalogDescription")}

+
+ {knownProviders().map((prov) => { + const plan = getKnownPlan(prov); + if (!plan) return null; + return ( +
+
+ +
+
+
{prov}
+ {plan.dimensions.map((d, i) => ( +
+ {d.unit}/{d.window}: {d.limit} +
+ ))} +
+
+ ); + })} +
+
+
+ + {/* Right: plan config */} + {selectedConnectionId ? ( +
+ {/* Status badge */} +
+ {selectedProvider && ( +
+ +
+ )} + {connLabel(selectedConn!)} + {detectedSource === "auto" && ( + + {t("detectedPlanLabel")} (auto) + + )} + {detectedSource === "manual" && ( + + {t("manualPlanLabel")} + + )} + {!detectedSource && ( + + {t("unconfiguredLabel")} + + )} +
+ + {/* Dimensions editor */} +
+
+ + {t("dimensionLabel")} + + +
+ + {editDimensions.length === 0 && ( +
+ {t("unconfiguredLabel")} — {t("addDimension")} +
+ )} + +
+ {editDimensions.map((dim, i) => ( +
+ + + updateDimension(i, { limit: Number(e.target.value) })} + placeholder={t("limitLabel")} + className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs tabular-nums text-right" + /> + +
+ ))} +
+
+ + {/* Error / success */} + {error && ( +

{error}

+ )} + {successMsg && ( +

+ {successMsg} +

+ )} + + {/* Actions */} +
+ + {existingOverride && existingOverride.source === "manual" && ( + + )} +
+
+ ) : ( +
+ {t("unknownProviderNotice")} +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/plans/page.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/plans/page.tsx new file mode 100644 index 0000000000..97c8c3c1cc --- /dev/null +++ b/src/app/(dashboard)/dashboard/costs/quota-share/plans/page.tsx @@ -0,0 +1,7 @@ +import ProviderPlanConfigClient from "./ProviderPlanConfigClient"; + +export const dynamic = "force-dynamic"; + +export default function PlansPage() { + return ; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2c06b216dd..88ff5b4c11 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -836,6 +836,7 @@ "analyticsCompression": "Compression", "costsBudget": "Budget", "costsQuotaShare": "Quota Sharing", + "costsQuotaPlans": "Plans & Quotas", "costsPricing": "Pricing", "logsProxy": "Proxy Logs", "logsConsole": "Console", @@ -903,6 +904,7 @@ "costsPricingSubtitle": "Per-model pricing rules", "costsBudgetSubtitle": "Budget limits", "costsQuotaShareSubtitle": "Share provider quotas across keys", + "costsQuotaPlansSubtitle": "Configure plans by provider", "auditLogSubtitle": "Authorization audit", "auditMcpSubtitle": "MCP server audit", "auditA2aSubtitle": "A2A protocol audit", @@ -7299,7 +7301,54 @@ "betaConfigSavedSuffix": "(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.", "policyLabel": "Policy:", "resetIn": "reset in", - "quotaTotal": "total" + "quotaTotal": "total", + "conceptTitle": "How Quota Share works", + "conceptIntro": "Quota Share divides a provider's quota among multiple API keys using work-conserving fair-share: each key receives a proportional slice but can borrow from the free balance without exceeding the global cap.", + "conceptFairShare": "Fair-share: each key receives quota proportional to its configured weight", + "conceptBorrowing": "Borrowing: keys can consume free balance from others without breaching the cap", + "conceptGlobalCap": "Hard global cap: the provider's absolute limit is never exceeded", + "conceptWindows": "Windows: 5h, hourly, daily, weekly, monthly — each tracked independently", + "burnRateTitle": "Burn rate", + "burnRateExhaustsIn": "Exhausts in", + "dimensionResetIn": "Resets in", + "realConsumedColumn": "Consumed", + "deficitColumn": "Deficit", + "borrowingIndicator": "borrowing", + "migratedFromLocalStorageNotice": "Pools successfully migrated from localStorage.", + "policyCapAbsoluteLabel": "Absolute cap", + "policyCapAbsolutePlaceholder": "Numeric limit (optional)", + "multiDimensionLabel": "Multi-dimension" + }, + "quotaPlans": { + "title": "Plans & Quotas", + "description": "Configure quota plans per provider — dimensions (%, requests, tokens, $) and time windows", + "providerLabel": "Provider / Connection", + "detectedPlanLabel": "Detected plan", + "manualPlanLabel": "Manual override", + "unconfiguredLabel": "Not configured — manual required", + "dimensionLabel": "Dimensions", + "addDimension": "Add dimension", + "removeDimension": "Remove", + "limitLabel": "Limit", + "unitOptions": { + "percent": "%", + "requests": "requests", + "tokens": "tokens", + "usd": "USD ($)" + }, + "windowOptions": { + "5h": "5 hours", + "hourly": "Hourly", + "daily": "Daily", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "useCatalogButton": "Use catalog", + "saveOverrideButton": "Save override", + "revertToCatalogButton": "Revert to catalog", + "unknownProviderNotice": "Select a provider on the left to configure its quota plan.", + "catalogTitle": "Known catalog", + "catalogDescription": "Automatically detected plans for the following providers:" }, "activity": { "title": "Activity", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 208e1fdb38..52ee2eece4 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -836,6 +836,7 @@ "analyticsCompression": "Compression", "costsBudget": "Budget", "costsQuotaShare": "Compartilhamento de Cota", + "costsQuotaPlans": "Planos & Cotas", "costsPricing": "Pricing", "logsProxy": "Proxy Logs", "logsConsole": "Console", @@ -903,6 +904,7 @@ "costsPricingSubtitle": "Regras de preço por modelo", "costsBudgetSubtitle": "Limites de orçamento", "costsQuotaShareSubtitle": "Compartilhe cotas de provedor entre chaves", + "costsQuotaPlansSubtitle": "Configurar planos por provider", "auditLogSubtitle": "Auditoria de autorização", "auditMcpSubtitle": "Auditoria do servidor MCP", "auditA2aSubtitle": "Auditoria do protocolo A2A", @@ -7289,7 +7291,54 @@ "betaConfigSavedSuffix": "(ainda não persistido no servidor). A aplicação do limite por solicitação ainda não está conectada ao pipeline do proxy. Esta tela permite desenhar e visualizar a divisão de cotas; a aplicação real chegará em uma iteração futura com persistência de banco de dados e interceptação de chamadas upstream.", "policyLabel": "Política:", "resetIn": "redefinir em", - "quotaTotal": "total" + "quotaTotal": "total", + "conceptTitle": "Como funciona o Quota Share", + "conceptIntro": "O Quota Share divide a cota de um provider entre múltiplas API keys usando fair-share work-conserving: cada key recebe uma fatia proporcional, mas pode tomar emprestado do saldo livre sem estourar o teto global.", + "conceptFairShare": "Fair-share: cada key recebe proporcionalmente ao peso configurado", + "conceptBorrowing": "Empréstimo: keys podem usar saldo livre de outras sem ultrapassar o teto", + "conceptGlobalCap": "Teto intransponível: o cap absoluto do provider nunca é excedido", + "conceptWindows": "Janelas: 5h, horária, diária, semanal, mensal — cada uma rastreada independentemente", + "burnRateTitle": "Taxa de consumo", + "burnRateExhaustsIn": "Esgota em", + "dimensionResetIn": "Reset em", + "realConsumedColumn": "Consumido", + "deficitColumn": "Déficit", + "borrowingIndicator": "empréstimo", + "migratedFromLocalStorageNotice": "Pools migrados do localStorage com sucesso.", + "policyCapAbsoluteLabel": "Cap absoluto", + "policyCapAbsolutePlaceholder": "Limite numérico (opcional)", + "multiDimensionLabel": "Multi-dimensão" + }, + "quotaPlans": { + "title": "Planos & Cotas", + "description": "Configure os planos de cota por provider — dimensões (%, requests, tokens, $) e janelas de tempo", + "providerLabel": "Provider / Conexão", + "detectedPlanLabel": "Plano detectado", + "manualPlanLabel": "Override manual", + "unconfiguredLabel": "Não configurado — manual obrigatório", + "dimensionLabel": "Dimensões", + "addDimension": "Adicionar dimensão", + "removeDimension": "Remover", + "limitLabel": "Limite", + "unitOptions": { + "percent": "%", + "requests": "requests", + "tokens": "tokens", + "usd": "USD ($)" + }, + "windowOptions": { + "5h": "5 horas", + "hourly": "Por hora", + "daily": "Diária", + "weekly": "Semanal", + "monthly": "Mensal" + }, + "useCatalogButton": "Usar catálogo", + "saveOverrideButton": "Salvar override", + "revertToCatalogButton": "Reverter para catálogo", + "unknownProviderNotice": "Selecione um provider à esquerda para configurar o plano de cota.", + "catalogTitle": "Catálogo conhecido", + "catalogDescription": "Planos automaticamente detectados para os seguintes providers:" }, "activity": { "title": "Atividade", diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index c45d9c196b..ffa663d84a 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -44,6 +44,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "costs-pricing", "costs-budget", "costs-quota-share", + "costs-quota-plans", // Monitoring > Audit "audit", "audit-mcp", @@ -437,8 +438,13 @@ const COSTS_ITEMS: readonly SidebarItemDefinition[] = [ subtitleKey: "costsQuotaShareSubtitle", icon: "pie_chart", }, - // F9 ADDS ONE LINE HERE: - // { id: "costs-quota-plans", href: "/dashboard/costs/quota-share/plans", i18nKey: "costsQuotaPlans", subtitleKey: "costsQuotaPlansSubtitle", icon: "fact_check" }, + { + id: "costs-quota-plans", + href: "/dashboard/costs/quota-share/plans", + i18nKey: "costsQuotaPlans", + subtitleKey: "costsQuotaPlansSubtitle", + icon: "fact_check", + }, ]; const AUDIT_GROUP: SidebarItemGroup = { diff --git a/tests/unit/sidebar-costs-quota-plans.test.ts b/tests/unit/sidebar-costs-quota-plans.test.ts new file mode 100644 index 0000000000..7ac5609c4d --- /dev/null +++ b/tests/unit/sidebar-costs-quota-plans.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts"); + +function sectionItems(sectionId: string) { + const section = sidebarVisibility.SIDEBAR_SECTIONS.find((s) => s.id === sectionId); + assert.ok(section, `expected section "${sectionId}" to exist`); + return sidebarVisibility.getSectionItems(section); +} + +test("HIDEABLE_SIDEBAR_ITEM_IDS contains costs-quota-plans", () => { + assert.ok( + (sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]).includes("costs-quota-plans"), + "costs-quota-plans must be in HIDEABLE_SIDEBAR_ITEM_IDS" + ); +}); + +test("HIDEABLE_SIDEBAR_ITEM_IDS: costs-quota-plans appears after costs-quota-share", () => { + const ids = sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS as readonly string[]; + const qsIdx = ids.indexOf("costs-quota-share"); + const qpIdx = ids.indexOf("costs-quota-plans"); + assert.ok(qsIdx !== -1, "costs-quota-share must exist"); + assert.ok(qpIdx !== -1, "costs-quota-plans must exist"); + assert.ok(qpIdx > qsIdx, "costs-quota-plans must come after costs-quota-share"); +}); + +test("costs section has 5 items including costs-quota-plans at end", () => { + const items = sectionItems("costs"); + const ids = items.map((i) => i.id); + assert.ok(ids.includes("costs-quota-plans"), "costs section must include costs-quota-plans"); + assert.strictEqual(ids[ids.length - 1], "costs-quota-plans", "costs-quota-plans must be last"); + assert.strictEqual(ids.length, 5, "costs section must have exactly 5 items"); +}); + +test("costs-quota-plans has correct href and icon", () => { + const items = sectionItems("costs"); + const item = items.find((i) => i.id === "costs-quota-plans"); + assert.ok(item, "costs-quota-plans item must exist"); + assert.strictEqual(item.href, "/dashboard/costs/quota-share/plans"); + assert.strictEqual(item.icon, "fact_check"); + assert.strictEqual(item.i18nKey, "costsQuotaPlans"); + assert.strictEqual(item.subtitleKey, "costsQuotaPlansSubtitle"); +}); diff --git a/tests/unit/ui/allocation-table.test.tsx b/tests/unit/ui/allocation-table.test.tsx new file mode 100644 index 0000000000..c03657eb6e --- /dev/null +++ b/tests/unit/ui/allocation-table.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: AllocationTable } = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable" +); + +const ALLOCATIONS = [ + { apiKeyId: "key_1", weight: 60, policy: "hard" as const }, + { apiKeyId: "key_2", weight: 40, policy: "soft" as const }, +]; + +const KEY_LABELS: Record = { key_1: "KeyOne", key_2: "KeyTwo" }; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function render(props: Parameters[0]) { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render(); + }); +} + +describe("AllocationTable", { timeout: 10000 }, () => { + afterEach(() => { + if (root && container) act(() => root!.unmount()); + container?.remove(); + container = null; + root = null; + }); + + it("renders empty state when no allocations", async () => { + await render({ allocations: [], usage: null, keyLabels: {} }); + expect(document.body.innerHTML).toContain("noAllocations"); + }); + + it("renders key labels", async () => { + await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS }); + expect(document.body.innerHTML).toContain("KeyOne"); + expect(document.body.innerHTML).toContain("KeyTwo"); + }); + + it("renders weights correctly", async () => { + await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS }); + expect(document.body.innerHTML).toContain("60%"); + expect(document.body.innerHTML).toContain("40%"); + }); + + it("renders policy badges", async () => { + await render({ allocations: ALLOCATIONS, usage: null, keyLabels: KEY_LABELS }); + expect(document.body.innerHTML).toContain("hard"); + expect(document.body.innerHTML).toContain("soft"); + }); + + it("renders consumed values from usage perKey data", async () => { + const usage = { + dimensions: [ + { + unit: "tokens", + window: "daily", + limit: 1000, + consumedTotal: 400, + perKey: [ + { apiKeyId: "key_1", consumed: 300, fairShare: 600, deficit: -300, borrowing: false }, + { apiKeyId: "key_2", consumed: 100, fairShare: 400, deficit: 300, borrowing: true }, + ], + }, + ], + burnRate: null, + }; + await render({ allocations: ALLOCATIONS, usage: usage as never, keyLabels: KEY_LABELS }); + expect(document.body.innerHTML).toContain("300"); + expect(document.body.innerHTML).toContain("100"); + // borrowing indicator for key_2 + expect(document.body.innerHTML).toContain("borrowingIndicator"); + }); +}); diff --git a/tests/unit/ui/burn-rate-chart.test.tsx b/tests/unit/ui/burn-rate-chart.test.tsx new file mode 100644 index 0000000000..2148fca4be --- /dev/null +++ b/tests/unit/ui/burn-rate-chart.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Stub next/dynamic — returns null component (recharts not needed in tests) +vi.mock("next/dynamic", () => ({ + default: () => () => null, +})); + +const { default: BurnRateChart } = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart" +); + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function render(props: Parameters[0]) { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render(); + }); +} + +describe("BurnRateChart", { timeout: 10000 }, () => { + afterEach(() => { + if (root && container) act(() => root!.unmount()); + container?.remove(); + container = null; + root = null; + }); + + it("renders no-data state when usage is null", async () => { + await render({ usage: null }); + expect(document.body.innerHTML).toContain("burnRateTitle"); + expect(document.body.innerHTML).toContain("no data"); + }); + + it("renders no-data state when burnRate is falsy", async () => { + const usage = { + dimensions: [], + burnRate: null, + }; + await render({ usage: usage as never }); + expect(document.body.innerHTML).toContain("no data"); + }); + + it("renders chart when usage has burnRate data", async () => { + const usage = { + dimensions: [ + { unit: "tokens", window: "daily", limit: 100000, consumedTotal: 30000, perKey: [] }, + ], + burnRate: { tokensPerSecond: 10, timeToExhaustionMs: 7_000_000 }, + }; + await render({ usage: usage as never }); + // Should not show no-data message + expect(document.body.innerHTML).not.toContain("no data yet"); + // Should show exhaustion label + expect(document.body.innerHTML).toContain("burnRateExhaustsIn"); + }); +}); diff --git a/tests/unit/ui/pool-card.test.tsx b/tests/unit/ui/pool-card.test.tsx new file mode 100644 index 0000000000..fc0bdd8d6b --- /dev/null +++ b/tests/unit/ui/pool-card.test.tsx @@ -0,0 +1,141 @@ +// @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"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/dynamic", () => ({ + default: () => () => null, +})); + +vi.mock("@/shared/components/Card", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: ({ providerId }: { providerId: string }) => ( + + ), +})); + +// Stub sub-components +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar", + () => ({ default: ({ dimension }: { dimension: { unit: string } }) =>
{dimension.unit}
}) +); +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable", + () => ({ default: () =>
}) +); +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart", + () => ({ default: () =>
}) +); + +const { default: PoolCard } = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard" +); + +const MOCK_POOL = { + id: "pool_1", + connectionId: "conn_1", + name: "Test Pool", + createdAt: new Date().toISOString(), + allocations: [{ apiKeyId: "key_1", weight: 60, policy: "hard" as const }], +}; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function renderCard(usage = null as null | object) { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render( + + ); + }); +} + +describe("PoolCard", { timeout: 10000 }, () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + if (root && container) { + act(() => root!.unmount()); + } + container?.remove(); + container = null; + root = null; + }); + + it("renders pool name and connection label", async () => { + await renderCard(); + expect(document.body.innerHTML).toContain("Test Pool"); + expect(document.body.innerHTML).toContain("My Conn"); + }); + + it("renders AllocationTable", async () => { + await renderCard(); + expect(document.querySelector("[data-testid='alloc-table']")).not.toBeNull(); + }); + + it("renders DimensionBar when usage has dimensions", async () => { + const usage = { + dimensions: [{ unit: "tokens", window: "daily", limit: 1000, consumedTotal: 500, perKey: [] }], + burnRate: null, + }; + await renderCard(usage); + expect(document.querySelector("[data-testid='dim-bar']")).not.toBeNull(); + }); + + it("renders BurnRateChart when usage is non-null", async () => { + const usage = { + dimensions: [{ unit: "tokens", window: "daily", limit: 1000, consumedTotal: 200, perKey: [] }], + burnRate: null, + }; + await renderCard(usage); + expect(document.querySelector("[data-testid='burn-rate-chart']")).not.toBeNull(); + }); + + it("calls onEdit when edit button clicked", async () => { + const onEdit = vi.fn(); + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render( + + ); + }); + const editBtn = document.querySelector("button[title='editAllocations']") as HTMLButtonElement; + expect(editBtn).not.toBeNull(); + await act(async () => editBtn.click()); + expect(onEdit).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/ui/provider-plan-config.test.tsx b/tests/unit/ui/provider-plan-config.test.tsx new file mode 100644 index 0000000000..0351a92a54 --- /dev/null +++ b/tests/unit/ui/provider-plan-config.test.tsx @@ -0,0 +1,134 @@ +// @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"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/shared/components", () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); + +vi.mock("@/lib/quota/planRegistry", () => ({ + knownProviders: () => ["openai", "anthropic"], + getKnownPlan: (prov: string) => { + if (prov === "openai") { + return { dimensions: [{ unit: "tokens", window: "daily", limit: 100000 }] }; + } + return null; + }, +})); + +const MOCK_CONNECTIONS = [ + { id: "conn_1", provider: "openai", name: "GPT Account" }, + { id: "conn_2", provider: "anthropic", email: "user@example.com" }, +]; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { default: ProviderPlanConfigClient } = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient" +); + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function renderPage() { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render(); + }); + // Wait for initial fetch effect to resolve + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); +} + +describe("ProviderPlanConfigClient", { timeout: 15000 }, () => { + beforeEach(() => { + mockFetch.mockImplementation((url: string) => { + if (String(url).includes("/api/providers/client")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ connections: MOCK_CONNECTIONS }), + } as unknown as Response); + } + if (String(url).includes("/api/quota/plans")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([]), + } as unknown as Response); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + } as unknown as Response); + }); + }); + + afterEach(() => { + if (root && container) act(() => root!.unmount()); + container?.remove(); + container = null; + root = null; + vi.clearAllMocks(); + }); + + it("renders the page title", async () => { + await renderPage(); + expect(document.body.innerHTML).toContain("title"); + }); + + it("renders catalog section with known providers", async () => { + await renderPage(); + // catalogTitle key should appear + expect(document.body.innerHTML).toContain("catalogTitle"); + expect(document.body.innerHTML).toContain("openai"); + }); + + it("renders connection selector with options", async () => { + await renderPage(); + const select = document.querySelector("select") as HTMLSelectElement; + expect(select).not.toBeNull(); + expect(select.options.length).toBeGreaterThan(1); + }); + + it("shows right-panel placeholder when no connection selected", async () => { + await renderPage(); + expect(document.body.innerHTML).toContain("unknownProviderNotice"); + }); + + it("renders save button after selecting a connection", async () => { + await renderPage(); + const select = document.querySelector("select") as HTMLSelectElement; + await act(async () => { + select.value = "conn_1"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(document.body.innerHTML).toContain("saveOverrideButton"); + }); +}); diff --git a/tests/unit/ui/quota-share-page.test.tsx b/tests/unit/ui/quota-share-page.test.tsx new file mode 100644 index 0000000000..7340d6d63d --- /dev/null +++ b/tests/unit/ui/quota-share-page.test.tsx @@ -0,0 +1,169 @@ +// @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"; + +// ── i18n stub ────────────────────────────────────────────────────────────── +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// ── next/dynamic stub ────────────────────────────────────────────────────── +vi.mock("next/dynamic", () => ({ + default: () => () => null, +})); + +// ── Shared component stubs ───────────────────────────────────────────────── +vi.mock("@/shared/components", () => ({ + Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( + + ), + Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => + isOpen ?
{children}
: null, +})); +vi.mock("@/shared/components/Card", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("@/shared/components/ProviderIcon", () => ({ + default: () => , +})); + +// ── Pool data ────────────────────────────────────────────────────────────── +const MOCK_POOLS = [ + { + id: "pool_1", + connectionId: "conn_1", + name: "Pool A", + createdAt: new Date().toISOString(), + allocations: [{ apiKeyId: "key_1", weight: 50, policy: "hard" }], + }, + { + id: "pool_2", + connectionId: "conn_2", + name: "Pool B", + createdAt: new Date().toISOString(), + allocations: [], + }, +]; + +// ── usePools mock ────────────────────────────────────────────────────────── +const mockMutate = vi.fn().mockResolvedValue(undefined); +const mockUsePools = vi.fn(() => ({ + pools: MOCK_POOLS, + loading: false, + error: null, + mutate: mockMutate, +})); + +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools", + () => ({ usePools: mockUsePools }) +); + +// ── usePoolUsage mock ────────────────────────────────────────────────────── +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage", + () => ({ + usePoolUsage: () => ({ usage: null, loading: false, error: null }), + }) +); + +// ── useLocalStoragePoolMigration mock ────────────────────────────────────── +const mockMigration = vi.fn(); +vi.mock( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration", + () => ({ useLocalStoragePoolMigration: mockMigration }) +); + +// ── fetch stub ───────────────────────────────────────────────────────────── +vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve([]) } as unknown as Response) + ) +); + +// ── Lazy import after mocks ──────────────────────────────────────────────── +const { default: QuotaSharePageClient } = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient" +); + +// ── Helpers ─────────────────────────────────────────────────────────────── + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function renderComponent() { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render(); + }); +} + +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("QuotaSharePageClient", { timeout: 15000 }, () => { + beforeEach(() => { + mockMigration.mockReset(); + vi.clearAllMocks(); + mockUsePools.mockReturnValue({ + pools: MOCK_POOLS, + loading: false, + error: null, + mutate: mockMutate, + }); + }); + + afterEach(() => { + if (root && container) { + act(() => { + root!.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + it("renders 2 PoolCard components when usePools returns 2 pools", async () => { + await renderComponent(); + await waitFor(() => { + // Each PoolCard renders the pool name text + return document.body.innerHTML.includes("Pool A"); + }); + expect(document.body.innerHTML).toContain("Pool A"); + expect(document.body.innerHTML).toContain("Pool B"); + }); + + it("renders empty state when pools is empty", async () => { + mockUsePools.mockReturnValue({ pools: [], loading: false, error: null, mutate: mockMutate }); + await renderComponent(); + await waitFor(() => document.body.innerHTML.includes("emptyTitle")); + expect(document.body.innerHTML).toContain("emptyTitle"); + }); + + it("calls useLocalStoragePoolMigration on mount", async () => { + await renderComponent(); + expect(mockMigration).toHaveBeenCalled(); + }); + + it("does not contain localStorage references in rendered output", async () => { + await renderComponent(); + expect(document.body.innerHTML).not.toContain("localStorage"); + expect(document.body.innerHTML).not.toContain("betaPreviewLabel"); + }); +}); diff --git a/tests/unit/ui/use-local-storage-pool-migration.test.tsx b/tests/unit/ui/use-local-storage-pool-migration.test.tsx new file mode 100644 index 0000000000..8bac55841d --- /dev/null +++ b/tests/unit/ui/use-local-storage-pool-migration.test.tsx @@ -0,0 +1,157 @@ +// @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"; + +const { + adaptLsPoolToApiSchema, + useLocalStoragePoolMigration, +} = await import( + "../../../src/app/(dashboard)/dashboard/costs/quota-share/hooks/useLocalStoragePoolMigration" +); + +// ── Unit tests for adaptLsPoolToApiSchema ───────────────────────────────── + +describe("adaptLsPoolToApiSchema", () => { + it("maps connectionId, accountLabel, and allocations", () => { + const lsPool = { + id: "old_1", + connectionId: "conn_abc", + accountLabel: "My Account", + policy: "soft" as const, + allocations: [{ apiKeyId: "k1", percent: 70 }, { apiKeyId: "k2", percent: 30 }], + }; + const result = adaptLsPoolToApiSchema(lsPool); + expect(result.connectionId).toBe("conn_abc"); + expect(result.name).toBe("My Account"); + expect(result.allocations).toHaveLength(2); + expect(result.allocations[0].weight).toBe(70); + expect(result.allocations[0].policy).toBe("soft"); + }); + + it("defaults policy to hard for unknown policy values", () => { + const lsPool = { connectionId: "c1", policy: "invalid", allocations: [] }; + const result = adaptLsPoolToApiSchema(lsPool); + expect(result.allocations).toHaveLength(0); + }); + + it("filters allocations without apiKeyId", () => { + const lsPool = { + connectionId: "c1", + allocations: [{ apiKeyId: "k1", percent: 100 }, { percent: 50 }], + }; + const result = adaptLsPoolToApiSchema(lsPool); + expect(result.allocations).toHaveLength(1); + expect(result.allocations[0].apiKeyId).toBe("k1"); + }); + + it("clamps weight to 0-100", () => { + const lsPool = { + connectionId: "c1", + allocations: [{ apiKeyId: "k1", percent: 150 }, { apiKeyId: "k2", percent: -10 }], + }; + const result = adaptLsPoolToApiSchema(lsPool); + expect(result.allocations[0].weight).toBe(100); + expect(result.allocations[1].weight).toBe(0); + }); + + it("uses provider as fallback name", () => { + const lsPool = { connectionId: "conn_xyz", provider: "openai", allocations: [] }; + const result = adaptLsPoolToApiSchema(lsPool); + expect(result.name).toBe("openai"); + }); +}); + +// ── Integration tests for useLocalStoragePoolMigration hook ─────────────── + +const LS_KEY = "omniroute:quota-share:pools"; + +function HookWrapper({ + pools, + mutate, +}: { + pools: object[]; + mutate: () => Promise; +}) { + useLocalStoragePoolMigration({ pools: pools as never, mutate }); + return
; +} + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +async function renderHook(props: Parameters[0]) { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + container = document.createElement("div"); + document.body.appendChild(container); + await act(async () => { + root = createRoot(container!); + root.render(); + }); +} + +describe("useLocalStoragePoolMigration", { timeout: 10000 }, () => { + const mockMutate = vi.fn().mockResolvedValue(undefined); + let fetchSpy: ReturnType; + + beforeEach(() => { + localStorage.clear(); + fetchSpy = vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as unknown as Response) + ); + vi.stubGlobal("fetch", fetchSpy); + mockMutate.mockClear(); + }); + + afterEach(() => { + if (root && container) act(() => root!.unmount()); + container?.remove(); + container = null; + root = null; + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it("does nothing when localStorage key is absent", async () => { + await renderHook({ pools: [], mutate: mockMutate }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("does not migrate when DB already has pools (idempotency)", async () => { + const lsPools = [{ connectionId: "c1", allocations: [] }]; + localStorage.setItem(LS_KEY, JSON.stringify(lsPools)); + const existingPools = [{ id: "p1", connectionId: "c1", name: "Existing", allocations: [] }]; + await renderHook({ pools: existingPools, mutate: mockMutate }); + // fetch not called — pools already exist + expect(fetchSpy).not.toHaveBeenCalled(); + // localStorage key preserved for user safety + expect(localStorage.getItem(LS_KEY)).not.toBeNull(); + }); + + it("migrates LS pools to API when DB is empty", async () => { + const lsPools = [ + { connectionId: "c1", accountLabel: "Acme", policy: "hard", allocations: [{ apiKeyId: "k1", percent: 100 }] }, + ]; + localStorage.setItem(LS_KEY, JSON.stringify(lsPools)); + await renderHook({ pools: [], mutate: mockMutate }); + // Small tick to let the Promise chain resolve + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + expect(fetchSpy).toHaveBeenCalledWith( + "/api/quota/pools", + expect.objectContaining({ method: "POST" }) + ); + expect(localStorage.getItem(LS_KEY)).toBeNull(); + expect(mockMutate).toHaveBeenCalled(); + }); + + it("clears invalid JSON from localStorage", async () => { + localStorage.setItem(LS_KEY, "{invalid}"); + await renderHook({ pools: [], mutate: mockMutate }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem(LS_KEY)).toBeNull(); + }); +});