feat(quota-share): add hooks (usePools + usePoolUsage + useLocalStoragePoolMigration) (B/F9)

This commit is contained in:
diegosouzapw
2026-05-28 08:24:38 -03:00
parent 160f0693f3
commit ec876883b2
3 changed files with 216 additions and 0 deletions

View File

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

View File

@@ -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<PoolUsageSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View File

@@ -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<void>;
}
export function usePools(): UsePoolsResult {
const [pools, setPools] = useState<QuotaPool[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}