diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx index 42f78f897a..4cfeb52d66 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx @@ -251,6 +251,25 @@ export default function QuotaSharePageClient() { setRenaming(false); }, [selectedGroupId, groups, fetchGroups, t]); + // Delete the selected group. The API blocks deletion while the group still has + // pools (HTTP 409) and protects the seed "group-demo"; surface both to the user. + const handleDeleteGroup = useCallback(async () => { + if (selectedGroupId === "all" || selectedGroupId === "group-demo") return; + if (!confirm(t("deleteGroupConfirm"))) return; + try { + const res = await fetch(`/api/quota/groups/${selectedGroupId}`, { method: "DELETE" }); + if (res.ok) { + setSelectedGroupId("all"); + await fetchGroups(); + await mutate(); + } else if (res.status === 409) { + alert(t("deleteGroupHasPools")); + } + } catch { + // fail open + } + }, [selectedGroupId, fetchGroups, mutate, t]); + // ── Derived ────────────────────────────────────────────────────────────── const keyLabels = useMemo(() => { @@ -274,6 +293,29 @@ export default function QuotaSharePageClient() { [connections] ); + // connectionId → name of the pool it already belongs to (all members, not just + // primary). Feeds the wizard's "already used" hint so the one-connection-per-pool + // rule is explicit instead of silently disabling a checkbox. + const connectionPoolName = useMemo(() => { + const map: Record = {}; + for (const p of pools) { + const name = (p as unknown as { name?: string }).name ?? p.id.slice(0, 8); + for (const cid of p.connectionIds ?? [p.connectionId]) { + if (!(cid in map)) map[cid] = name; + } + } + return map; + }, [pools]); + + // Pools whose groupId matches no loaded group (e.g. legacy pools saved with the + // "all" sentinel). Surfaced in an "Ungrouped" bucket so they stay editable/deletable. + const orphanPools = useMemo(() => { + const known = new Set(groups.map((g) => g.id)); + return pools.filter( + (p) => !known.has((p as unknown as { groupId?: string }).groupId ?? "group-demo") + ); + }, [pools, groups]); + const aggregate = usePoolsUsageAggregate(pools); const stats = useMemo( @@ -423,6 +465,16 @@ export default function QuotaSharePageClient() { {t("renameGroup")} )} + {selectedGroupId !== "all" && selectedGroupId !== "group-demo" && ( + + )} {/* Concept card */} @@ -531,6 +583,38 @@ export default function QuotaSharePageClient() { ); }) )} + + {/* Ungrouped bucket — pools whose group no longer matches (e.g. legacy + "all" sentinel). Keeps them visible + editable + deletable. */} + {selectedGroupId === "all" && orphanPools.length > 0 && ( +
+
+ + folder_off + + {t("ungroupedTitle")} + ({orphanPools.length}) +
+

{t("ungroupedHint")}

+
+ {orphanPools.map((pool) => ( + setEditing(pool)} + onRemove={() => void handleRemovePool(pool.id)} + /> + ))} +
+
+ )} )} @@ -542,7 +626,8 @@ export default function QuotaSharePageClient() { connections={connections} apiKeys={apiKeys} plans={plans} - existingPoolConnectionIds={new Set(pools.map((p) => p.connectionId))} + existingPoolConnectionIds={new Set(pools.flatMap((p) => p.connectionIds ?? [p.connectionId]))} + connectionPoolName={connectionPoolName} groups={groups} selectedGroupId={selectedGroupId} /> @@ -560,7 +645,14 @@ export default function QuotaSharePageClient() { connections={connections} apiKeys={apiKeys} plans={plans} - existingPoolConnectionIds={new Set(pools.filter((p) => p.id !== editing?.id).map((p) => p.connectionId))} + existingPoolConnectionIds={ + new Set( + pools + .filter((p) => p.id !== editing?.id) + .flatMap((p) => p.connectionIds ?? [p.connectionId]) + ) + } + connectionPoolName={connectionPoolName} groups={groups} selectedGroupId={selectedGroupId} /> diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx index 25803653e1..912dbef726 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -69,6 +69,8 @@ export interface PoolWizardProps { editPool?: QuotaPool; /** Whether the pool being edited is currently exclusive. Used to pre-fill the exclusive checkbox in edit mode. */ editPoolExclusive?: boolean; + /** connectionId → name of the pool it already belongs to, for the "already used" hint. */ + connectionPoolName?: Record; } // ──────────────────────────────────────────────────────────────────────────── @@ -176,6 +178,7 @@ export default function PoolWizard({ selectedGroupId: initialGroupId = "group-demo", editPool, editPoolExclusive, + connectionPoolName = {}, }: PoolWizardProps) { const t = useTranslations("quotaShare"); const tPlans = useTranslations("quotaPlans"); @@ -305,6 +308,18 @@ export default function PoolWizard({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, editPool, initialGroupId]); + // Keep the group void handleKeyChange(e.target.value)} - className="px-2 py-1 rounded border border-border bg-bg-base text-xs text-text-main min-w-[140px]" - > - - {apiKeys.map((k) => ( - - ))} - - - )} + {/* Key preview selector + collapse toggle */} +
+ {apiKeys.length > 0 && ( + <> + + + + )} + +
- {/* Base URL line */} -
- - {t("endpointsBaseUrl")} - - POST /v1/chat/completions - · - - model: "qtSd/<group>/<provider>/<model>" - + {!collapsed && ( + <> + {/* Base URL line(s) */} +
+
+ + {t("endpointsBaseUrl")} + + POST /v1/chat/completions + · + + model: "qtSd/<group>/<provider>/<model>" + +
+ {hasAnthropic && ( +
+ + {t("endpointsBaseUrl")} + + POST /v1/messages + · + + model: "qtSd/<group>/<provider>/<model>" + + ({t("endpointsAnthropicNote")}) +
+ )}
{/* Model listing */} @@ -284,6 +331,8 @@ export default function QuotaEndpointsCard({
)} + + )} ); } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 632824f380..85836dedee 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7934,7 +7934,15 @@ "endpointsHint": "Call these virtual models with any allocated key — routing + quota are handled per group.", "previewForKey": "Preview for key", "previewKeyNone": "(all endpoints)", - "endpointsBaseUrl": "Base URL" + "endpointsBaseUrl": "Base URL", + "endpointsCollapse": "Collapse", + "endpointsExpand": "Expand", + "endpointsAnthropicNote": "Anthropic-native", + "deleteGroup": "Delete group", + "deleteGroupConfirm": "Delete this group? Its pools must be reassigned or removed first.", + "deleteGroupHasPools": "This group still has pools — reassign or delete them first.", + "ungroupedTitle": "Ungrouped", + "ungroupedHint": "These pools are not assigned to a known group. Edit a pool to move it into a real group." }, "plugins": { "title": "Plugins", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index ebca3a88fa..843146b9dc 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5423,7 +5423,15 @@ "endpointsHint": "Chame estes modelos virtuais com qualquer chave alocada — roteamento + cota são tratados por grupo.", "previewForKey": "Pré-visualizar p/ chave", "previewKeyNone": "(todos os endpoints)", - "endpointsBaseUrl": "URL base" + "endpointsBaseUrl": "URL base", + "endpointsCollapse": "Recolher", + "endpointsExpand": "Expandir", + "endpointsAnthropicNote": "nativo Anthropic", + "deleteGroup": "Excluir grupo", + "deleteGroupConfirm": "Excluir este grupo? Os pools dele precisam ser reatribuídos ou removidos antes.", + "deleteGroupHasPools": "Este grupo ainda tem pools — reatribua ou exclua-os primeiro.", + "ungroupedTitle": "Sem grupo", + "ungroupedHint": "Estes pools não estão atribuídos a um grupo conhecido. Edite um pool para movê-lo para um grupo real." }, "requestLogger": { "recording": "Recording", diff --git a/tests/unit/quota-share-bugfixes-v388.test.ts b/tests/unit/quota-share-bugfixes-v388.test.ts new file mode 100644 index 0000000000..1b5530a07e --- /dev/null +++ b/tests/unit/quota-share-bugfixes-v388.test.ts @@ -0,0 +1,160 @@ +/** + * tests/unit/quota-share-bugfixes-v388.test.ts + * + * Source-level assertions for the v3.8.8 Quota Share bug fixes reported from + * Local-VPS testing. Pattern mirrors quota-share-layout-v2.test.ts (source-scan + * + i18n parity) — no DOM setup required. + * + * B1 pools created "in a group" persisted with the "all" sentinel → hidden, + * uneditable, undeletable. Fix: wizard never persists "all"; page shows an + * "Ungrouped" recovery bucket for orphan pools. + * B3 one-connection-per-pool made explicit (member set + "already used" pool name). + * B4 delete-group control wired in the UI (backend already existed). + * B5a native Anthropic POST /v1/messages surfaced in the endpoints card. + * B5b endpoints card collapse/minimize toggle. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const QS = "src/app/(dashboard)/dashboard/costs/quota-share"; + +const pageSrc = readFileSync(join(ROOT, QS, "QuotaSharePageClient.tsx"), "utf8"); +const wizardSrc = readFileSync(join(ROOT, QS, "components/PoolWizard.tsx"), "utf8"); +const endpointsSrc = readFileSync(join(ROOT, QS, "components/QuotaEndpointsCard.tsx"), "utf8"); +const en = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/en.json"), "utf8")) as { + quotaShare: Record; +}; +const pt = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/pt-BR.json"), "utf8")) as { + quotaShare: Record; +}; + +// ── B1 — wizard never persists the "all" sentinel as a real group ──────────── + +test("B1: PoolWizard resolves groupId away from 'all' before persisting", () => { + assert.ok( + wizardSrc.includes("const resolvedGroupId ="), + "wizard must compute a resolvedGroupId before saving" + ); + assert.ok( + /resolvedGroupId\s*=[\s\S]*?groupId\s*!==\s*"all"/.test(wizardSrc), + "resolvedGroupId must guard against the 'all' sentinel" + ); + // Both the create POST body and the edit PATCH body must send the resolved id, + // never the raw groupId state. + const groupIdSends = wizardSrc.match(/groupId:\s*resolvedGroupId/g) ?? []; + assert.ok( + groupIdSends.length >= 2, + `both create + edit bodies must send groupId: resolvedGroupId (found ${groupIdSends.length})` + ); + assert.ok( + !/\n\s*groupId,\n/.test(wizardSrc), + "wizard must not send the bare groupId shorthand in a request body" + ); +}); + +// ── B1 — page surfaces orphan pools so stuck pools stay actionable ─────────── + +test("B1: QuotaSharePageClient renders an Ungrouped bucket for orphan pools", () => { + assert.ok(pageSrc.includes("const orphanPools = useMemo"), "must compute orphanPools"); + assert.ok( + pageSrc.includes('t("ungroupedTitle")'), + "must render the ungrouped section heading" + ); + // Orphan pools must reuse the same card with edit/remove wiring. + const orphanBlock = pageSrc.slice(pageSrc.indexOf('t("ungroupedTitle")')); + assert.ok( + orphanBlock.includes("orphanPools.map") && + orphanBlock.includes("onEdit") && + orphanBlock.includes("onRemove"), + "orphan pools must render PoolCard with edit + remove controls" + ); +}); + +// ── B3 — one connection per pool, made explicit ────────────────────────────── + +test("B3: connection→pool membership is explicit (all members, not just primary)", () => { + assert.ok( + pageSrc.includes("const connectionPoolName = useMemo"), + "page must build a connectionId→pool-name map" + ); + assert.ok( + pageSrc.includes("connectionPoolName={connectionPoolName}"), + "page must pass connectionPoolName into the wizard" + ); + // existingPoolConnectionIds must include every member connection, not only the primary. + assert.ok( + pageSrc.includes("flatMap((p) => p.connectionIds ?? [p.connectionId])"), + "existingPoolConnectionIds must span all member connections" + ); + assert.ok( + wizardSrc.includes("connectionPoolName[c.id]"), + "wizard must show which pool an already-used connection belongs to" + ); +}); + +// ── B4 — delete-group control wired in the UI ──────────────────────────────── + +test("B4: QuotaSharePageClient wires a delete-group control (protecting the seed)", () => { + assert.ok(pageSrc.includes("const handleDeleteGroup = useCallback"), "must define handleDeleteGroup"); + assert.ok( + pageSrc.includes('method: "DELETE" }') && pageSrc.includes("/api/quota/groups/"), + "handleDeleteGroup must DELETE the group via the API" + ); + assert.ok( + pageSrc.includes('res.status === 409') && pageSrc.includes('t("deleteGroupHasPools")'), + "must handle the 409 (group still has pools) response" + ); + assert.ok( + pageSrc.includes('selectedGroupId !== "all" && selectedGroupId !== "group-demo"'), + "delete control must be hidden for 'all' and the protected seed group" + ); +}); + +// ── B5a — native Anthropic endpoint ────────────────────────────────────────── + +test("B5a: endpoints card surfaces POST /v1/messages for Anthropic providers", () => { + assert.ok(endpointsSrc.includes("const hasAnthropic"), "must detect Anthropic providers in scope"); + assert.ok(endpointsSrc.includes("POST /v1/messages"), "must show the native Anthropic endpoint"); + assert.ok( + /isAnthropicProvider[\s\S]*?startsWith\("claude"\)/.test(endpointsSrc), + "Anthropic detection must cover claude* providers" + ); +}); + +// ── B5b — collapse toggle ──────────────────────────────────────────────────── + +test("B5b: endpoints card has a collapse/expand toggle", () => { + assert.ok(endpointsSrc.includes("const [collapsed, setCollapsed]"), "must hold a collapsed state"); + assert.ok( + endpointsSrc.includes("{!collapsed && ("), + "the card body must be hidden while collapsed" + ); + assert.ok( + endpointsSrc.includes('t("endpointsCollapse")') && endpointsSrc.includes('t("endpointsExpand")'), + "toggle must use collapse/expand labels" + ); +}); + +// ── i18n parity for every new key ──────────────────────────────────────────── + +test("i18n: new quotaShare keys exist in both en and pt-BR", () => { + const keys = [ + "deleteGroup", + "deleteGroupConfirm", + "deleteGroupHasPools", + "ungroupedTitle", + "ungroupedHint", + "endpointsCollapse", + "endpointsExpand", + "endpointsAnthropicNote", + ]; + for (const k of keys) { + assert.ok(en.quotaShare[k], `en.json quotaShare.${k} must exist`); + assert.ok(pt.quotaShare[k], `pt-BR.json quotaShare.${k} must exist`); + } +});