From c7ee32186c6dd9b287349e97f67c7da154f319ba Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 08:24:44 -0300 Subject: [PATCH] refactor(quota-share): move pools from localStorage to /api/quota/pools (B/F9) --- .../quota-share/QuotaSharePageClient.tsx | 1092 ++++------------- 1 file changed, 245 insertions(+), 847 deletions(-) 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)} + /> + )} +
); }