mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
merge: F9 UI quota-share refactor (DB-backed + Plans page + sidebar + i18n)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import ProviderPlanConfigClient from "./ProviderPlanConfigClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function PlansPage() {
|
||||
return <ProviderPlanConfigClient />;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
44
tests/unit/sidebar-costs-quota-plans.test.ts
Normal file
44
tests/unit/sidebar-costs-quota-plans.test.ts
Normal 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");
|
||||
});
|
||||
89
tests/unit/ui/allocation-table.test.tsx
Normal file
89
tests/unit/ui/allocation-table.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
70
tests/unit/ui/burn-rate-chart.test.tsx
Normal file
70
tests/unit/ui/burn-rate-chart.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
141
tests/unit/ui/pool-card.test.tsx
Normal file
141
tests/unit/ui/pool-card.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
134
tests/unit/ui/provider-plan-config.test.tsx
Normal file
134
tests/unit/ui/provider-plan-config.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
169
tests/unit/ui/quota-share-page.test.tsx
Normal file
169
tests/unit/ui/quota-share-page.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
157
tests/unit/ui/use-local-storage-pool-migration.test.tsx
Normal file
157
tests/unit/ui/use-local-storage-pool-migration.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user