feat(quota): multi-connection pool wizard — select N providers, all models available (Phase D3)

- PoolCreateSchema: add optional connectionIds[] + .refine() that enforces primary membership
- PoolWizard: replace single-select dropdown with checkbox multi-select; first checked = primary (badge); step-2 adds helper note for additional connections; step-3 preview grouped by provider with +N more; POST body sends both connectionId and connectionIds
- PoolCard: optional providers[] prop renders a row of ProviderIcon (up to 3 + badge) instead of a single icon when pool has multiple connections
- i18n: 4 new keys added to both en.json and pt-BR.json (wizardConnectionsLabel, wizardPrimaryBadge, wizardAdditionalConnectionsNote, wizardPreviewMoreModels) — parity maintained (23 wizard keys each)
- Tests: quota-pool-wizard-multi.test.ts (21 tests) covering schema accept/reject, structural wizard assertions, and i18n parity
This commit is contained in:
diegosouzapw
2026-05-30 23:03:21 -03:00
parent e5a624d0ec
commit acd517eb1e
6 changed files with 388 additions and 62 deletions

View File

@@ -17,8 +17,10 @@ export interface PoolCardProps {
keyLabels: Record<string, string>;
/** Connection display label */
connectionLabel: string;
/** Provider identifier */
/** Primary provider identifier */
provider: string;
/** Optional list of all provider identifiers when pool has multiple connections */
providers?: string[];
onEdit: () => void;
onRemove: () => void;
}
@@ -47,6 +49,7 @@ export default function PoolCard({
keyLabels,
connectionLabel,
provider,
providers,
onEdit,
onRemove,
}: PoolCardProps) {
@@ -62,9 +65,28 @@ export default function PoolCard({
{/* 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>
{/* Provider icon(s): show one per provider when multi-connection, else single icon */}
{providers && providers.length > 1 ? (
<div className="flex items-center gap-0.5 shrink-0">
{providers.slice(0, 3).map((p) => (
<div
key={p}
className="w-5 h-5 rounded flex items-center justify-center overflow-hidden bg-bg-subtle"
>
<ProviderIcon providerId={p} size={20} type="color" />
</div>
))}
{providers.length > 3 && (
<span className="text-[10px] text-text-muted font-semibold ml-0.5">
+{providers.length - 3}
</span>
)}
</div>
) : (
<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}`}>

View File

@@ -165,7 +165,8 @@ export default function PoolWizard({
const [step, setStep] = useState<1 | 2 | 3>(1);
// ── Step 1 state ──────────────────────────────────────────────────────────
const [connectionId, setConnectionId] = useState("");
// Multi-select: ordered array of selected connection IDs. First element is primary.
const [connectionIds, setConnectionIds] = useState<string[]>([]);
const [poolName, setPoolName] = useState("");
const [defaultPolicy, setDefaultPolicy] = useState<Policy>("hard");
@@ -181,14 +182,18 @@ export default function PoolWizard({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── Derived state ─────────────────────────────────────────────────────────
// The first selected connection is the "primary" — used for plan fetch/PUT.
const primaryConnectionId = connectionIds[0] ?? "";
// ── Helpers ───────────────────────────────────────────────────────────────
const connLabel = (c: Connection) =>
`${c.provider} / ${c.name || c.email || c.displayName || c.id.slice(0, 12)}`;
const selectedConn = useMemo(
() => connections.find((c) => c.id === connectionId),
[connections, connectionId]
() => connections.find((c) => c.id === primaryConnectionId),
[connections, primaryConnectionId]
);
const availableConnections = useMemo(
@@ -196,15 +201,15 @@ export default function PoolWizard({
[connections, existingPoolConnectionIds]
);
// ── Load dimensions when connection changes ───────────────────────────────
// ── Load dimensions when primary connection changes ───────────────────────
useEffect(() => {
if (!connectionId) {
if (!primaryConnectionId) {
setEditDimensions([]);
setDimensionsEdited(false);
return;
}
const existingPlan = plans[connectionId];
const existingPlan = plans[primaryConnectionId];
if (existingPlan && existingPlan.dimensions.length > 0) {
setEditDimensions([...existingPlan.dimensions]);
} else {
@@ -213,14 +218,14 @@ export default function PoolWizard({
}
setDimensionsEdited(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectionId]);
}, [primaryConnectionId]);
// ── Reset wizard on open/close ────────────────────────────────────────────
useEffect(() => {
if (!open) {
setStep(1);
setConnectionId("");
setConnectionIds([]);
setPoolName("");
setDefaultPolicy("hard");
setEditDimensions([]);
@@ -296,21 +301,35 @@ export default function PoolWizard({
// ── Preview model names ───────────────────────────────────────────────────
const previewNames = useMemo(() => {
if (!selectedConn || !poolName.trim()) return [];
const models = getPreviewModels(selectedConn.provider);
const MAX = 6;
return models.slice(0, MAX).map((m) =>
quotaModelName(poolName.trim(), selectedConn.provider, m)
);
}, [selectedConn, poolName]);
// Per-provider preview: { provider, names[], totalModels }
const previewByProvider = useMemo(() => {
const name = poolName.trim();
if (connectionIds.length === 0 || !name) return [];
const MAX_PER_PROVIDER = 3;
return connectionIds.map((cid) => {
const conn = connections.find((c) => c.id === cid);
if (!conn) return null;
const allModels = getPreviewModels(conn.provider);
const names = allModels.slice(0, MAX_PER_PROVIDER).map((m) =>
quotaModelName(name, conn.provider, m)
);
return { provider: conn.provider, names, totalModels: allModels.length };
}).filter(Boolean) as Array<{ provider: string; names: string[]; totalModels: number }>;
}, [connectionIds, connections, poolName]);
// Flat list (for legacy single-provider path, kept for step-3 rendering simplicity)
const previewNames = useMemo(
() => previewByProvider.flatMap((p) => p.names),
[previewByProvider]
);
const effectivePoolName = poolName.trim() || (selectedConn ? connLabel(selectedConn) : "");
// ── Save sequence ─────────────────────────────────────────────────────────
const handleFinish = async () => {
if (!selectedConn) return;
if (!selectedConn || connectionIds.length === 0) return;
setSaving(true);
setError(null);
@@ -320,7 +339,8 @@ export default function PoolWizard({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
connectionId,
connectionId: primaryConnectionId,
connectionIds,
name: effectivePoolName,
allocations: [],
}),
@@ -334,9 +354,9 @@ export default function PoolWizard({
const createData = (await createRes.json()) as { pool: { id: string } };
const newPoolId = createData.pool.id;
// 2. PUT /api/quota/plans/[connectionId] — only when user edited dimensions
// 2. PUT /api/quota/plans/[primaryConnectionId] — only when user edited dimensions
if (dimensionsEdited && editDimensions.length > 0) {
const planRes = await fetch(`/api/quota/plans/${connectionId}`, {
const planRes = await fetch(`/api/quota/plans/${primaryConnectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dimensions: editDimensions }),
@@ -390,40 +410,72 @@ export default function PoolWizard({
<p className="text-[11px] text-text-muted">{t("wizardStep1Subtitle")}</p>
</div>
{/* Connection selector */}
{/* Connection multi-select (checkboxes) */}
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("providerConnection")}
{t("wizardConnectionsLabel")}
</label>
<select
value={connectionId}
onChange={(e) => {
setConnectionId(e.target.value);
setPoolName("");
}}
className="w-full px-3 py-2 rounded border border-border bg-bg-base text-sm"
>
<option value="">{t("selectConnection")}</option>
{availableConnections.map((c) => (
<option key={c.id} value={c.id}>
{connLabel(c)}
</option>
))}
<div className="space-y-1.5 rounded border border-border bg-bg-base px-3 py-2 max-h-48 overflow-y-auto">
{availableConnections.map((c) => {
const checked = connectionIds.includes(c.id);
const isPrimary = connectionIds[0] === c.id;
return (
<label
key={c.id}
className="flex items-center gap-2 cursor-pointer select-none py-0.5"
>
<input
type="checkbox"
checked={checked}
onChange={() => {
setConnectionIds((prev) => {
if (prev.includes(c.id)) {
const next = prev.filter((id) => id !== c.id);
if (next.length === 0) setPoolName("");
return next;
} else {
return [...prev, c.id];
}
});
}}
className="accent-primary w-3.5 h-3.5 shrink-0"
/>
<span className="text-sm truncate">{connLabel(c)}</span>
{isPrimary && (
<span className="text-[10px] font-semibold text-primary bg-primary/10 px-1.5 py-0.5 rounded shrink-0">
{t("wizardPrimaryBadge")}
</span>
)}
</label>
);
})}
{connections
.filter((c) => existingPoolConnectionIds.has(c.id))
.map((c) => (
<option key={c.id} value={c.id} disabled>
{connLabel(c)} {t("alreadyUsedSuffix")}
</option>
<label
key={c.id}
className="flex items-center gap-2 cursor-not-allowed select-none py-0.5 opacity-40"
>
<input
type="checkbox"
disabled
checked={false}
readOnly
className="w-3.5 h-3.5 shrink-0"
/>
<span className="text-sm truncate">
{connLabel(c)} {t("alreadyUsedSuffix")}
</span>
</label>
))}
</select>
</div>
{connections.length === 0 && (
<p className="text-[10px] text-amber-400 mt-1">{t("noEligibleConnections")}</p>
)}
</div>
{/* Pool name */}
{connectionId && (
{connectionIds.length > 0 && (
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("wizardPoolNameLabel")}
@@ -439,7 +491,7 @@ export default function PoolWizard({
)}
{/* Default policy */}
{connectionId && (
{connectionIds.length > 0 && (
<div>
<label className="text-[11px] uppercase tracking-wide text-text-muted font-semibold block mb-1">
{t("policyLabel")}
@@ -466,7 +518,7 @@ export default function PoolWizard({
<div className="flex justify-end pt-2">
<button
onClick={() => setStep(2)}
disabled={!connectionId}
disabled={connectionIds.length === 0}
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("wizardNext")}
@@ -563,6 +615,13 @@ export default function PoolWizard({
<p className="text-[10px] text-amber-400">{t("wizardDimensionsEditedNotice")}</p>
)}
{/* Helper note when pool has multiple connections */}
{connectionIds.length > 1 && (
<p className="text-[10px] text-text-muted bg-bg-subtle/40 px-3 py-2 rounded border border-border/40">
{t("wizardAdditionalConnectionsNote")}
</p>
)}
<div className="flex items-center justify-between pt-2">
<button
onClick={() => setStep(1)}
@@ -715,18 +774,37 @@ export default function PoolWizard({
</div>
</label>
{/* quotaModelName preview */}
{previewNames.length > 0 && (
{/* quotaModelName preview — grouped by provider */}
{previewByProvider.length > 0 && (
<div className="rounded-md border border-border/40 bg-bg-subtle/30 p-3 text-[11px]">
<div className="font-semibold text-text-muted uppercase tracking-wide mb-1.5 text-[10px]">
{t("wizardPreviewLabel")}
</div>
<div className="space-y-0.5">
{previewNames.map((name) => (
<div key={name} className="font-mono text-text-main truncate">
{name}
</div>
))}
<div className="space-y-2">
{previewByProvider.map(({ provider, names, totalModels }) => {
const extra = totalModels - names.length;
return (
<div key={provider}>
{previewByProvider.length > 1 && (
<div className="text-[10px] uppercase tracking-wide text-text-muted font-semibold mb-0.5">
{provider}
</div>
)}
<div className="space-y-0.5">
{names.map((name) => (
<div key={name} className="font-mono text-text-main truncate">
{name}
</div>
))}
{extra > 0 && (
<div className="text-text-muted italic">
{t("wizardPreviewMoreModels", { count: extra })}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}

View File

@@ -7885,7 +7885,11 @@
"wizardDimensionsEditedNotice": "Dimensions edited — will be saved as a manual override when the pool is created.",
"wizardExclusiveLabel": "Exclusive quota",
"wizardExclusiveHint": "When enabled, these API keys will only be allowed to use this pool's virtual models (allowedQuotas reconciliation is applied on save).",
"wizardPreviewLabel": "Virtual model names preview"
"wizardPreviewLabel": "Virtual model names preview",
"wizardConnectionsLabel": "Provider connections",
"wizardPrimaryBadge": "primary",
"wizardAdditionalConnectionsNote": "Additional connections use their catalog default limits (editable later).",
"wizardPreviewMoreModels": "+{count} more"
},
"plugins": {
"title": "Plugins",

View File

@@ -5395,7 +5395,11 @@
"wizardDimensionsEditedNotice": "Dimensões editadas — serão salvas como override manual quando o pool for criado.",
"wizardExclusiveLabel": "Cota exclusiva",
"wizardExclusiveHint": "Quando ativado, estas API keys só poderão usar os modelos virtuais deste pool (reconciliação de allowedQuotas aplicada ao salvar).",
"wizardPreviewLabel": "Prévia dos nomes de modelos virtuais"
"wizardPreviewLabel": "Prévia dos nomes de modelos virtuais",
"wizardConnectionsLabel": "Conexões de provider",
"wizardPrimaryBadge": "principal",
"wizardAdditionalConnectionsNote": "Conexões adicionais usam os limites padrão do catálogo (editáveis posteriormente).",
"wizardPreviewMoreModels": "+{count} mais"
},
"requestLogger": {
"recording": "Recording",

View File

@@ -1,11 +1,20 @@
import { z } from "zod";
import { PoolAllocationSchema, QuotaDimensionSchema } from "@/lib/quota/dimensions";
export const PoolCreateSchema = z.object({
connectionId: z.string().min(1),
name: z.string().min(1).max(120),
allocations: z.array(PoolAllocationSchema).default([]),
});
export const PoolCreateSchema = z
.object({
connectionId: z.string().min(1),
connectionIds: z.array(z.string().min(1)).min(1).optional(),
name: z.string().min(1).max(120),
allocations: z.array(PoolAllocationSchema).default([]),
})
.refine(
(data) => {
if (data.connectionIds === undefined) return true;
return data.connectionIds.includes(data.connectionId);
},
{ message: "primary connectionId must be one of connectionIds" }
);
export type PoolCreate = z.infer<typeof PoolCreateSchema>;
export const PoolUpdateSchema = z.object({

View File

@@ -0,0 +1,209 @@
/**
* Phase D3 — Multi-connection pool wizard tests.
*
* Tests cover:
* 1. PoolCreateSchema accepts/rejects connectionIds combinations
* 2. Structural assertions on PoolWizard.tsx (multi-select, POST body, step-3 preview)
* 3. i18n parity: new wizard keys exist in both locales
*
* Node native test runner — no JSdom needed (pure schema + source analysis).
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { PoolCreateSchema } from "../../src/shared/schemas/quota.js";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const WIZARD_PATH = path.join(
ROOT,
"src",
"app",
"(dashboard)",
"dashboard",
"costs",
"quota-share",
"components",
"PoolWizard.tsx"
);
const EN_PATH = path.join(ROOT, "src", "i18n", "messages", "en.json");
const PT_PATH = path.join(ROOT, "src", "i18n", "messages", "pt-BR.json");
const wizardSrc = fs.readFileSync(WIZARD_PATH, "utf-8");
const en = JSON.parse(fs.readFileSync(EN_PATH, "utf-8")) as { quotaShare: Record<string, string> };
const pt = JSON.parse(fs.readFileSync(PT_PATH, "utf-8")) as { quotaShare: Record<string, string> };
// ── PoolCreateSchema: connectionIds field ─────────────────────────────────────
test("PoolCreateSchema accepts multi-connection input when primary is a member", () => {
const result = PoolCreateSchema.safeParse({
connectionId: "a",
connectionIds: ["a", "b"],
name: "x",
allocations: [],
});
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
if (result.success) {
assert.deepEqual(result.data.connectionIds, ["a", "b"]);
assert.equal(result.data.connectionId, "a");
}
});
test("PoolCreateSchema rejects when primary connectionId is NOT in connectionIds", () => {
const result = PoolCreateSchema.safeParse({
connectionId: "z",
connectionIds: ["a", "b"],
name: "x",
allocations: [],
});
assert.equal(result.success, false, "Expected refine to reject when primary not in connectionIds");
const msg = result.error?.issues[0]?.message ?? "";
assert.ok(
msg.includes("primary connectionId must be one of connectionIds"),
`Expected refine message, got: "${msg}"`
);
});
test("PoolCreateSchema accepts single-connection input without connectionIds (back-compat)", () => {
const result = PoolCreateSchema.safeParse({ connectionId: "c", name: "Pool" });
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
if (result.success) {
assert.equal(result.data.connectionIds, undefined);
assert.equal(result.data.connectionId, "c");
}
});
test("PoolCreateSchema rejects empty connectionIds array", () => {
const result = PoolCreateSchema.safeParse({
connectionId: "a",
connectionIds: [],
name: "x",
allocations: [],
});
assert.equal(result.success, false, "Expected failure for empty connectionIds");
});
test("PoolCreateSchema accepts connectionIds with single element matching connectionId", () => {
const result = PoolCreateSchema.safeParse({
connectionId: "solo",
connectionIds: ["solo"],
name: "solo pool",
allocations: [],
});
assert.ok(result.success, `Expected success, got: ${result.error?.message}`);
});
// ── PoolWizard.tsx structural assertions ──────────────────────────────────────
test("PoolWizard.tsx: connectionIds state is defined (multi-select)", () => {
assert.ok(
wizardSrc.includes("connectionIds"),
"Expected connectionIds state in PoolWizard"
);
assert.ok(
wizardSrc.includes("useState<string[]>([])"),
"Expected connectionIds initialized as string[] state"
);
});
test("PoolWizard.tsx: renders checkboxes for connection selection in step 1", () => {
assert.ok(
wizardSrc.includes('type="checkbox"'),
"Expected checkbox inputs in step 1 for multi-connection selection"
);
});
test("PoolWizard.tsx: primaryConnectionId is derived from connectionIds[0]", () => {
assert.ok(
wizardSrc.includes("primaryConnectionId = connectionIds[0]"),
"Expected primaryConnectionId derived from connectionIds[0]"
);
});
test("PoolWizard.tsx: POST body sends both connectionId and connectionIds", () => {
assert.ok(
wizardSrc.includes("connectionId: primaryConnectionId"),
"Expected connectionId: primaryConnectionId in POST body"
);
assert.ok(
wizardSrc.includes("connectionIds,"),
"Expected connectionIds spread in POST body"
);
});
test("PoolWizard.tsx: step-3 preview maps over connectionIds (previewByProvider)", () => {
assert.ok(
wizardSrc.includes("previewByProvider"),
"Expected previewByProvider useMemo in PoolWizard"
);
assert.ok(
wizardSrc.includes("connectionIds.map((cid)"),
"Expected connectionIds.map to build per-provider preview"
);
});
test("PoolWizard.tsx: step-2 shows additional connections note when multiple selected", () => {
assert.ok(
wizardSrc.includes("wizardAdditionalConnectionsNote"),
"Expected wizardAdditionalConnectionsNote i18n key in step 2"
);
assert.ok(
wizardSrc.includes("connectionIds.length > 1"),
"Expected guard connectionIds.length > 1 for the note"
);
});
test("PoolWizard.tsx: primary badge rendered for first selected connection", () => {
assert.ok(
wizardSrc.includes("wizardPrimaryBadge"),
"Expected wizardPrimaryBadge i18n key in step 1 checkbox list"
);
});
// ── i18n parity: new wizard keys ─────────────────────────────────────────────
const NEW_KEYS = [
"wizardConnectionsLabel",
"wizardPrimaryBadge",
"wizardAdditionalConnectionsNote",
"wizardPreviewMoreModels",
];
for (const key of NEW_KEYS) {
test(`i18n en.json has key quotaShare.${key}`, () => {
assert.ok(
Object.prototype.hasOwnProperty.call(en.quotaShare, key),
`en.json missing quotaShare.${key}`
);
assert.equal(typeof en.quotaShare[key], "string", `quotaShare.${key} must be a string in en.json`);
assert.ok(en.quotaShare[key].length > 0, `quotaShare.${key} must not be empty in en.json`);
});
test(`i18n pt-BR.json has key quotaShare.${key}`, () => {
assert.ok(
Object.prototype.hasOwnProperty.call(pt.quotaShare, key),
`pt-BR.json missing quotaShare.${key}`
);
assert.equal(typeof pt.quotaShare[key], "string", `quotaShare.${key} must be a string in pt-BR.json`);
assert.ok(pt.quotaShare[key].length > 0, `quotaShare.${key} must not be empty in pt-BR.json`);
});
}
test("i18n parity: all quotaShare.wizard* keys are in sync between en and pt-BR", () => {
const enWizardKeys = Object.keys(en.quotaShare)
.filter((k) => k.startsWith("wizard"))
.sort();
const ptWizardKeys = Object.keys(pt.quotaShare)
.filter((k) => k.startsWith("wizard"))
.sort();
assert.deepEqual(
enWizardKeys,
ptWizardKeys,
`wizard* key parity mismatch.\nen: ${JSON.stringify(enWizardKeys)}\npt: ${JSON.stringify(ptWizardKeys)}`
);
});