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 }; +}