From 3dca2bb3f146e88635a89c21e4b77c87e3ef8243 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 2 Jun 2026 02:07:24 -0300 Subject: [PATCH] fix(quota-share): hidden pools, delete-group UI, endpoints card (Anthropic + collapse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs found while testing the Quota Share engine on the local VPS: - B1 hidden/stuck pools: pools created while the page group filter was "all" were persisted with group_id="all", matched no real group, and rendered nowhere — so they could not be seen, edited or deleted. PoolWizard now resolves the group id away from the "all" sentinel before POST/PATCH (falls back to the first real group / seed group-demo), and QuotaSharePageClient renders an "Ungrouped" recovery bucket so already-orphaned pools stay editable/deletable. - B3 one-connection-per-pool made explicit: existingPoolConnectionIds now spans every member connection (not just the primary), and the wizard shows which pool an already-used connection belongs to instead of silently disabling it. - B4 delete group: wired the missing UI control + handler (handleDeleteGroup, 409-aware) — the backend DELETE handler + deleteGroup already existed. Hidden for "all" and the protected seed group-demo. - B5a endpoints card now surfaces the native Anthropic POST /v1/messages line when a claude*/anthropic provider is in scope (previously only /v1/chat/completions). - B5b endpoints card gained a collapse/minimize toggle (the card was too tall). Source-scan tests + en/pt-BR i18n parity in quota-share-bugfixes-v388.test.ts. The larger quota-key redesign (key type bound to a group, default-restricted with opt-in normal-model access, recoverable keys, api-keys page layout) is planned separately in _tasks/features-v3.8.8/quota-share-key-redesign.plan.md. --- .../quota-share/QuotaSharePageClient.tsx | 96 ++++++++++- .../quota-share/components/PoolWizard.tsx | 27 ++- .../components/QuotaEndpointsCard.tsx | 109 ++++++++---- src/i18n/messages/en.json | 10 +- src/i18n/messages/pt-BR.json | 10 +- tests/unit/quota-share-bugfixes-v388.test.ts | 160 ++++++++++++++++++ 6 files changed, 376 insertions(+), 36 deletions(-) create mode 100644 tests/unit/quota-share-bugfixes-v388.test.ts 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`); + } +});