merge: F9 UI quota-share refactor (DB-backed + Plans page + sidebar + i18n)

This commit is contained in:
diegosouzapw
2026-05-28 08:29:31 -03:00
23 changed files with 2684 additions and 851 deletions

View File

@@ -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<string, string>;
}
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 (
<div className="text-[11px] text-text-muted italic py-3 text-center bg-bg-subtle/40 rounded-md">
{t("noAllocations")}
</div>
);
}
// Build per-key consumption lookup from first dimension (primary)
const primaryDim = usage?.dimensions?.[0];
return (
<div className="overflow-x-auto">
<table className="w-full text-[11px]">
<thead>
<tr className="text-[10px] uppercase tracking-wide text-text-muted border-b border-border/40">
<th className="text-left py-1 pr-2 font-semibold">API Key</th>
<th className="text-right py-1 pr-2 font-semibold">Weight</th>
<th className="text-right py-1 pr-2 font-semibold">{t("realConsumedColumn")}</th>
<th className="text-right py-1 pr-2 font-semibold">{t("deficitColumn")}</th>
<th className="text-right py-1 font-semibold">Policy</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={alloc.apiKeyId} className="border-b border-border/20 last:border-0">
<td className="py-1.5 pr-2">
<div className="flex items-center gap-1.5 min-w-0">
<span
className="inline-block w-2.5 h-2.5 rounded-sm shrink-0"
style={{ background: color }}
/>
<span className="font-mono truncate text-text-main">{label}</span>
{borrowing && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-400 font-bold shrink-0"
title={t("borrowingIndicator")}
>
{t("borrowingIndicator")}
</span>
)}
</div>
</td>
<td className="py-1.5 pr-2 text-right font-bold tabular-nums" style={{ color }}>
{alloc.weight}%
</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-text-muted">
{consumed !== null ? consumed.toLocaleString() : "—"}
</td>
<td className="py-1.5 pr-2 text-right tabular-nums">
{deficit !== null ? (
<span
className={
deficit > 0 ? "text-red-400" : deficit < 0 ? "text-emerald-400" : "text-text-muted"
}
>
{deficit > 0 ? "+" : ""}{deficit.toLocaleString()}
</span>
) : (
<span className="text-text-muted"></span>
)}
{fairShare !== null && (
<span className="text-[9px] text-text-muted ml-1">
(fair: {fairShare.toLocaleString()})
</span>
)}
</td>
<td className="py-1.5 text-right">
<span
className={`text-[9px] px-1.5 py-0.5 rounded font-semibold ${
alloc.policy === "hard"
? "bg-red-500/10 text-red-400"
: alloc.policy === "soft"
? "bg-amber-500/10 text-amber-400"
: "bg-emerald-500/10 text-emerald-400"
}`}
>
{alloc.policy}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -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 (
<div className="h-20 flex items-center justify-center rounded-md bg-bg-subtle/30 border border-border/30">
<p className="text-[11px] text-text-muted italic">{t("burnRateTitle")} no data yet</p>
</div>
);
}
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 (
<div className="space-y-1">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span className="font-semibold uppercase tracking-wide">{t("burnRateTitle")}</span>
{exhaustionLabel && <span className="text-amber-400 font-semibold">{exhaustionLabel}</span>}
</div>
<div className="h-24">
<RechartsResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={data}>
<RechartsXAxis dataKey="time" tick={{ fontSize: 9 }} tickLine={false} axisLine={false} />
<RechartsYAxis hide />
<RechartsTooltip
contentStyle={{
background: "var(--bg-surface, #1e1e2e)",
border: "1px solid var(--border)",
fontSize: 10,
}}
/>
<RechartsLine
type="monotone"
dataKey="consumed"
stroke="#a78bfa"
strokeWidth={2}
dot={false}
strokeDasharray="4 2"
/>
</RechartsLineChart>
</RechartsResponsiveContainer>
</div>
</div>
);
}
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`;
}

View File

@@ -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<string, PlanInfo>;
existingPools: QuotaPool[];
onClose: () => void;
onCreate: (pool: Omit<QuotaPool, "id" | "createdAt">) => Promise<void>;
}
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<Policy>("hard");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal isOpen onClose={onClose} title={t("newPoolTitle")}>
<div className="space-y-3">
{/* Connection selector */}
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("providerConnection")}
</label>
<select
value={connectionId}
onChange={(e) => {
setConnectionId(e.target.value);
setName("");
}}
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
>
<option value="">{t("selectConnection")}</option>
{connections.map((c) => (
<option key={c.id} value={c.id} disabled={usedConnectionIds.has(c.id)}>
{connLabel(c)} {usedConnectionIds.has(c.id) ? t("alreadyUsedSuffix") : ""}
</option>
))}
</select>
{connections.length === 0 && (
<p className="text-[10px] text-amber-400 mt-1">{t("noEligibleConnections")}</p>
)}
</div>
{/* Pool name */}
{connectionId && (
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
Pool name
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
)}
{/* Default policy */}
{connectionId && (
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("policyLabel")}
</label>
<div className="flex gap-1">
{(["hard", "soft", "burst"] as Policy[]).map((p) => (
<button
key={p}
type="button"
onClick={() => setDefaultPolicy(p)}
className={`px-3 py-1.5 rounded-md border text-xs cursor-pointer transition-colors ${
defaultPolicy === p
? "bg-primary/15 border-primary/40 text-primary font-semibold"
: "border-border text-text-muted hover:text-text-main"
}`}
>
{p === "hard" ? t("policyHard") : p === "soft" ? t("policySoft") : t("policyBurst")}
</button>
))}
</div>
</div>
)}
{/* Plan info */}
{connectionId && hasPlan && (
<div className="rounded-md border border-border/40 bg-bg-subtle/30 p-3 text-[11px] text-text-muted">
<div className="font-semibold text-text-main mb-1">
{t("multiDimensionLabel")} ({planInfo.source})
</div>
{planInfo.dimensions.map((d, i) => (
<div key={i}>
{d.unit} / {d.window}: {d.limit}
</div>
))}
</div>
)}
{/* Cap absolute notice */}
{connectionId && (
<div className="text-[10px] text-text-muted">
<span className="font-semibold">{t("policyCapAbsoluteLabel")}:</span>{" "}
{t("policyCapAbsolutePlaceholder")}
</div>
)}
{error && (
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
{t("cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!selectedConn || saving}
>
{saving ? t("loading") : t("createPool")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span className="font-semibold uppercase tracking-wide">
{dimension.unit} / {dimension.window}
</span>
<span className="tabular-nums font-bold" style={{ color: usedPct >= 90 ? "#f87171" : usedPct >= 70 ? "#fbbf24" : undefined }}>
{Math.round(usedPct)}%
</span>
</div>
<div className="h-1.5 rounded-sm bg-black/6 dark:bg-white/6 overflow-hidden">
<div className={`h-full rounded-sm transition-all ${barColor}`} style={{ width: `${usedPct}%` }} />
</div>
{countdown && (
<div className="text-[10px] text-text-muted">
{t("dimensionResetIn")} {countdown}
</div>
)}
</div>
);
}

View File

@@ -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<void>;
}
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<PoolAllocation[]>(pool.allocations);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal isOpen onClose={onClose} title={t("editTitle")} size="lg">
<div className="space-y-3">
<div className="text-xs text-text-muted">
{t("pool")}: <strong className="text-text-main">{pool.name}</strong>
</div>
{drafts.length === 0 ? (
<div className="text-[12px] text-text-muted italic py-4 text-center bg-bg-subtle/40 rounded-md">
{t("noKeysAdded")}
</div>
) : (
<div className="space-y-2">
{drafts.map((a, i) => {
const color = SLICE_PALETTE[i % SLICE_PALETTE.length];
return (
<div
key={a.apiKeyId}
className="grid items-center gap-2"
style={{ gridTemplateColumns: "12px minmax(0,1fr) 70px 80px 90px 24px" }}
>
<span
className="inline-block w-3 h-3 rounded-sm"
style={{ background: color }}
/>
<span className="text-[12px] font-mono truncate">{keyLabel(a.apiKeyId)}</span>
<input
type="number"
min={0}
max={100}
value={a.weight}
onChange={(e) => 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 */}
<input
type="number"
min={0}
value={a.capValue ?? ""}
onChange={(e) =>
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 */}
<select
value={a.policy}
onChange={(e) => updatePolicy(a.apiKeyId, e.target.value as Policy)}
className="px-1 py-1 rounded border border-border bg-bg-base text-xs"
>
<option value="hard">{t("policyHard")}</option>
<option value="soft">{t("policySoft")}</option>
<option value="burst">{t("policyBurst")}</option>
</select>
<button
type="button"
onClick={() => removeKey(a.apiKeyId)}
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
);
})}
</div>
)}
<div className="flex items-center justify-between text-[11px] pt-2 border-t border-border/40">
<span
className={`font-bold tabular-nums ${
totalWeight === 100
? "text-emerald-400"
: totalWeight > 100
? "text-red-400"
: "text-amber-400"
}`}
>
{t("totalLabel", { percent: totalWeight })}{" "}
{totalWeight > 100 && t("totalExceeded")}
</span>
<div className="flex items-center gap-2">
{availableKeys.length > 0 && (
<select
value=""
onChange={(e) => e.target.value && addKey(e.target.value)}
className="px-2 py-1 rounded border border-border bg-bg-base text-xs"
>
<option value="">{t("addKey")}</option>
{availableKeys.map((k) => (
<option key={k.id} value={k.id}>
{k.name || shortId(k.id)}
</option>
))}
</select>
)}
<Button
variant="secondary"
size="sm"
onClick={equalSplit}
disabled={drafts.length === 0}
>
{t("equalSplit")}
</Button>
</div>
</div>
{error && (
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-border/40">
<Button variant="secondary" size="sm" onClick={onClose} disabled={saving}>
{t("cancel")}
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSave}
disabled={totalWeight > 100 || saving}
>
{saving ? t("loading") : t("save")}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -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<string, string>;
/** 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 (
<Card padding="md">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-md flex items-center justify-center overflow-hidden shrink-0 bg-bg-subtle">
<ProviderIcon providerId={provider} size={28} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px] shrink-0 {statusCls}">
{statusIcon}
</span>
<span className={`text-[14px] shrink-0 ${statusCls}`}>
<span className="material-symbols-outlined text-[16px]">{statusIcon}</span>
</span>
<span className="text-sm font-bold text-text-main truncate">
{pool.name} · {connectionLabel}
</span>
</div>
<div className="text-[11px] text-text-muted">
{t("allocationsCount", { count: pool.allocations.length })} · ID: {pool.id.slice(0, 12)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={onEdit}
title={t("editAllocations")}
className="p-1.5 rounded-md hover:bg-bg-subtle text-text-muted hover:text-text-main cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">edit</span>
</button>
<button
type="button"
onClick={onRemove}
title={t("removePool")}
className="p-1.5 rounded-md hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">delete</span>
</button>
</div>
</div>
{/* Dimensions side-by-side */}
{hasDimensions ? (
<div
className="grid gap-3 mb-3"
style={{
gridTemplateColumns: `repeat(${Math.min(usage.dimensions.length, 3)}, 1fr)`,
}}
>
{usage.dimensions.map((dim, i) => (
<DimensionBar
key={`${dim.unit}-${dim.window}-${i}`}
dimension={{ unit: dim.unit, window: dim.window, limit: dim.limit }}
consumedTotal={dim.consumedTotal}
/>
))}
</div>
) : (
<div className="text-[11px] text-text-muted italic mb-3">
{t("multiDimensionLabel")} {t("loading")}
</div>
)}
{/* Allocation table */}
<div className="mb-3">
<h4 className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-1.5">
Allocations
</h4>
<AllocationTable
allocations={pool.allocations}
usage={usage}
keyLabels={keyLabels}
/>
</div>
{/* Burn rate chart */}
{usage && (
<div className="pt-2 border-t border-border/30">
<BurnRateChart usage={usage} />
</div>
)}
</Card>
);
}

View File

@@ -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 (
<Card padding="md">
<button
type="button"
className="w-full flex items-center justify-between gap-2 cursor-pointer"
onClick={() => setExpanded((p) => !p)}
aria-expanded={expanded}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[20px] text-primary">info</span>
<span className="text-sm font-semibold text-text-main">{t("conceptTitle")}</span>
</div>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{expanded ? "expand_less" : "expand_more"}
</span>
</button>
{expanded && (
<div className="mt-3 space-y-2 text-xs text-text-muted leading-relaxed">
<p>{t("conceptIntro")}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pt-1">
<ConceptItem icon="balance" text={t("conceptFairShare")} />
<ConceptItem icon="trending_up" text={t("conceptBorrowing")} />
<ConceptItem icon="lock" text={t("conceptGlobalCap")} />
<ConceptItem icon="schedule" text={t("conceptWindows")} />
</div>
</div>
)}
</Card>
);
}
function ConceptItem({ icon, text }: { icon: string; text: string }) {
return (
<div className="flex items-start gap-1.5 rounded-md bg-bg-subtle/40 p-2">
<span className="material-symbols-outlined text-[16px] text-primary shrink-0 mt-0.5">
{icon}
</span>
<span>{text}</span>
</div>
);
}

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

View File

@@ -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<Connection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState("");
const [overrides, setOverrides] = useState<Record<string, ProviderPlanOverride>>({});
const [editDimensions, setEditDimensions] = useState<QuotaDimension[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [reverting, setReverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(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<string, ProviderPlanOverride> = {};
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<QuotaDimension>) => {
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 (
<div className="flex flex-col gap-4">
{/* Header */}
<div>
<h1 className="text-xl font-bold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[24px] text-primary">fact_check</span>
{t("title")}
</h1>
<p className="text-sm text-text-muted mt-0.5">{t("description")}</p>
</div>
{loading ? (
<div className="text-text-muted text-sm py-10 text-center animate-pulse">Loading</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-4">
{/* Left: connection selector */}
<div className="flex flex-col gap-3">
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("providerLabel")}
</label>
<select
value={selectedConnectionId}
onChange={(e) => setSelectedConnectionId(e.target.value)}
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
>
<option value=""> {t("providerLabel")} </option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{connLabel(c)}
</option>
))}
</select>
</div>
{/* Catalog known plans */}
<div className="rounded-lg border border-border/40 bg-bg-subtle/20 p-3">
<div className="text-[10px] uppercase tracking-wide font-bold text-text-muted mb-2">
{t("catalogTitle")}
</div>
<p className="text-[11px] text-text-muted mb-2">{t("catalogDescription")}</p>
<div className="space-y-1.5">
{knownProviders().map((prov) => {
const plan = getKnownPlan(prov);
if (!plan) return null;
return (
<div
key={prov}
className="flex items-start gap-2 text-[11px] rounded-md bg-bg-subtle/30 px-2 py-1.5"
>
<div className="w-4 h-4 mt-0.5 rounded-sm overflow-hidden shrink-0">
<ProviderIcon providerId={prov} size={16} type="color" />
</div>
<div className="min-w-0">
<div className="font-semibold text-text-main capitalize">{prov}</div>
{plan.dimensions.map((d, i) => (
<div key={i} className="text-text-muted">
{d.unit}/{d.window}: {d.limit}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Right: plan config */}
{selectedConnectionId ? (
<div className="flex flex-col gap-3">
{/* Status badge */}
<div className="flex items-center gap-2 text-xs">
{selectedProvider && (
<div className="w-5 h-5 rounded-sm overflow-hidden">
<ProviderIcon providerId={selectedProvider} size={20} type="color" />
</div>
)}
<span className="font-semibold text-text-main">{connLabel(selectedConn!)}</span>
{detectedSource === "auto" && (
<span className="px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 text-[10px] font-bold">
{t("detectedPlanLabel")} (auto)
</span>
)}
{detectedSource === "manual" && (
<span className="px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 text-[10px] font-bold">
{t("manualPlanLabel")}
</span>
)}
{!detectedSource && (
<span className="px-2 py-0.5 rounded bg-amber-500/10 text-amber-400 text-[10px] font-bold">
{t("unconfiguredLabel")}
</span>
)}
</div>
{/* Dimensions editor */}
<div className="rounded-lg border border-border/40 bg-bg-subtle/10 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] uppercase tracking-wide font-bold text-text-muted">
{t("dimensionLabel")}
</span>
<button
type="button"
onClick={addDimension}
className="text-[11px] text-primary hover:underline cursor-pointer flex items-center gap-1"
>
<span className="material-symbols-outlined text-[14px]">add</span>
{t("addDimension")}
</button>
</div>
{editDimensions.length === 0 && (
<div className="text-[11px] text-text-muted italic py-3 text-center">
{t("unconfiguredLabel")} {t("addDimension")}
</div>
)}
<div className="space-y-2">
{editDimensions.map((dim, i) => (
<div key={i} className="grid items-center gap-2" style={{ gridTemplateColumns: "1fr 1fr 90px 24px" }}>
<select
value={dim.unit}
onChange={(e) => updateDimension(i, { unit: e.target.value as QuotaUnit })}
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
>
{UNIT_OPTIONS.map((u) => (
<option key={u} value={u}>
{t(`unitOptions.${u}`)}
</option>
))}
</select>
<select
value={dim.window}
onChange={(e) => updateDimension(i, { window: e.target.value as QuotaWindow })}
className="px-2 py-1.5 rounded border border-border bg-bg-base text-xs"
>
{WINDOW_OPTIONS.map((w) => (
<option key={w} value={w}>
{t(`windowOptions.${w}`)}
</option>
))}
</select>
<input
type="number"
min={0}
value={dim.limit}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => removeDimension(i)}
className="p-0.5 rounded hover:bg-red-500/10 text-text-muted hover:text-red-400 cursor-pointer"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
</div>
{/* Error / success */}
{error && (
<p className="text-[11px] text-red-400 bg-red-500/10 px-3 py-2 rounded">{error}</p>
)}
{successMsg && (
<p className="text-[11px] text-emerald-400 bg-emerald-500/10 px-3 py-2 rounded">
{successMsg}
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="primary"
size="sm"
onClick={handleSaveOverride}
disabled={saving || editDimensions.length === 0}
>
{saving ? "Saving…" : t("saveOverrideButton")}
</Button>
{existingOverride && existingOverride.source === "manual" && (
<Button
variant="secondary"
size="sm"
onClick={handleRevertToCatalog}
disabled={reverting}
>
{reverting ? "Reverting…" : t("revertToCatalogButton")}
</Button>
)}
</div>
</div>
) : (
<div className="flex items-center justify-center py-16 text-text-muted text-sm">
{t("unknownProviderNotice")}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import ProviderPlanConfigClient from "./ProviderPlanConfigClient";
export const dynamic = "force-dynamic";
export default function PlansPage() {
return <ProviderPlanConfigClient />;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 = {

View File

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

View File

@@ -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<string, string> = { key_1: "KeyOne", key_2: "KeyTwo" };
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
async function render(props: Parameters<typeof AllocationTable>[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(<AllocationTable {...props} />);
});
}
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");
});
});

View File

@@ -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<typeof createRoot> | null = null;
async function render(props: Parameters<typeof BurnRateChart>[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(<BurnRateChart {...props} />);
});
}
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");
});
});

View File

@@ -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 }) => <div data-testid="card">{children}</div>,
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: ({ providerId }: { providerId: string }) => (
<span data-testid={`provider-icon-${providerId}`} />
),
}));
// Stub sub-components
vi.mock(
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/DimensionBar",
() => ({ default: ({ dimension }: { dimension: { unit: string } }) => <div data-testid="dim-bar">{dimension.unit}</div> })
);
vi.mock(
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/AllocationTable",
() => ({ default: () => <div data-testid="alloc-table" /> })
);
vi.mock(
"../../../src/app/(dashboard)/dashboard/costs/quota-share/components/BurnRateChart",
() => ({ default: () => <div data-testid="burn-rate-chart" /> })
);
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<typeof createRoot> | 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(
<PoolCard
pool={MOCK_POOL}
usage={usage as never}
keyLabels={{ key_1: "MyKey" }}
connectionLabel="My Conn"
provider="openai"
onEdit={vi.fn()}
onRemove={vi.fn()}
/>
);
});
}
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(
<PoolCard
pool={MOCK_POOL}
usage={null}
keyLabels={{}}
connectionLabel="label"
provider="openai"
onEdit={onEdit}
onRemove={vi.fn()}
/>
);
});
const editBtn = document.querySelector("button[title='editAllocations']") as HTMLButtonElement;
expect(editBtn).not.toBeNull();
await act(async () => editBtn.click());
expect(onEdit).toHaveBeenCalledOnce();
});
});

View File

@@ -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;
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: () => <span />,
}));
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<typeof createRoot> | 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(<ProviderPlanConfigClient />);
});
// 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");
});
});

View File

@@ -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 }) => (
<button onClick={onClick}>{children}</button>
),
Modal: ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) =>
isOpen ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("@/shared/components/Card", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/shared/components/ProviderIcon", () => ({
default: () => <span />,
}));
// ── 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<typeof createRoot> | 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(<QuotaSharePageClient />);
});
}
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");
});
});

View File

@@ -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<unknown>;
}) {
useLocalStoragePoolMigration({ pools: pools as never, mutate });
return <div />;
}
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
async function renderHook(props: Parameters<typeof HookWrapper>[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(<HookWrapper {...props} />);
});
}
describe("useLocalStoragePoolMigration", { timeout: 10000 }, () => {
const mockMutate = vi.fn().mockResolvedValue(undefined);
let fetchSpy: ReturnType<typeof vi.fn>;
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();
});
});