fix(quota-share): hidden pools, delete-group UI, endpoints card (Anthropic + collapse)

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.
This commit is contained in:
diegosouzapw
2026-06-02 02:07:24 -03:00
parent 8386ab3084
commit 3dca2bb3f1
6 changed files with 376 additions and 36 deletions

View File

@@ -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<string, string> = {};
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")}
</button>
)}
{selectedGroupId !== "all" && selectedGroupId !== "group-demo" && (
<button
type="button"
onClick={() => void handleDeleteGroup()}
className="flex items-center gap-1 text-xs text-text-muted hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">delete</span>
{t("deleteGroup")}
</button>
)}
</div>
{/* 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 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-amber-400">
folder_off
</span>
<span className="text-sm font-semibold text-text-main">{t("ungroupedTitle")}</span>
<span className="text-[11px] text-text-muted">({orphanPools.length})</span>
</div>
<p className="text-[11px] text-amber-400/80">{t("ungroupedHint")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{orphanPools.map((pool) => (
<PoolCardWithUsage
key={pool.id}
pool={pool}
keyLabels={keyLabels}
connectionLabel={connLabel(pool.connectionId)}
provider={connProvider(pool.connectionId)}
providers={[
...new Set((pool.connectionIds ?? [pool.connectionId]).map(connProvider)),
]}
connectionIds={pool.connectionIds ?? [pool.connectionId]}
onEdit={() => setEditing(pool)}
onRemove={() => void handleRemovePool(pool.id)}
/>
))}
</div>
</div>
)}
</>
)}
@@ -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}
/>

View File

@@ -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<string, string>;
}
// ────────────────────────────────────────────────────────────────────────────
@@ -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 <select> on a real, selectable option: if the inherited page
// filter was "all" (or an unknown id), snap to the first real group once groups
// load. Prevents persisting groupId="all" (which renders under no group → B1).
useEffect(() => {
if (!open || editPool) return;
if (groups.length === 0) return;
if (groupId === "all" || !groups.some((g) => g.id === groupId)) {
setGroupId(groups[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editPool, groups]);
// ── Step 2 — dimension editors ────────────────────────────────────────────
const addDimension = () => {
@@ -409,6 +424,13 @@ export default function PoolWizard({
setSaving(true);
setError(null);
// Never persist the "all" filter sentinel (or an unknown id) as a real group.
// Fall back to the first real group — the seed "group-demo" always exists (migration 088).
const resolvedGroupId =
groupId && groupId !== "all" && groups.some((g) => g.id === groupId)
? groupId
: (groups[0]?.id ?? "group-demo");
try {
if (!editPool) {
// ── Create mode: POST → optional PUT → PATCH ──────────────────────
@@ -422,7 +444,7 @@ export default function PoolWizard({
connectionIds,
name: effectivePoolName,
allocations: [],
groupId,
groupId: resolvedGroupId,
}),
});
if (!createRes.ok) {
@@ -471,7 +493,7 @@ export default function PoolWizard({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: effectivePoolName,
groupId,
groupId: resolvedGroupId,
connectionIds,
allocations,
exclusive,
@@ -589,6 +611,7 @@ export default function PoolWizard({
/>
<span className="text-sm truncate">
{connLabel(c)} {t("alreadyUsedSuffix")}
{connectionPoolName[c.id] ? `${connectionPoolName[c.id]}` : ""}
</span>
</label>
))}

View File

@@ -87,6 +87,23 @@ export default function QuotaEndpointsCard({
const [selectedKeyId, setSelectedKeyId] = useState<string>("");
const [previewModels, setPreviewModels] = useState<string[] | null>(null);
const [loadingPreview, setLoadingPreview] = useState(false);
const [collapsed, setCollapsed] = useState(false);
// Anthropic-format providers (claude*/anthropic) can be called on the native
// Messages endpoint too, so we surface POST /v1/messages when one is in scope.
const isAnthropicProvider = (provider: string) =>
provider === "anthropic" || provider.startsWith("claude");
const hasAnthropic = useMemo(() => {
const provs = new Set<string>();
for (const pool of pools) {
for (const cid of pool.connectionIds ?? [pool.connectionId]) {
const conn = connections.find((c) => c.id === cid);
if (conn) provs.add(conn.provider);
}
}
return [...provs].some(isAnthropicProvider);
}, [pools, connections]);
// ── Derive default model list from groups + pools + connections ──────────────
// For each group, collect all pools that belong to it, then for each pool's
@@ -183,38 +200,68 @@ export default function QuotaEndpointsCard({
</div>
</div>
{/* Key preview selector */}
{apiKeys.length > 0 && (
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-text-muted whitespace-nowrap">
{t("previewForKey")}
</label>
<select
value={selectedKeyId}
onChange={(e) => 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]"
>
<option value="">{t("previewKeyNone")}</option>
{apiKeys.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k)}
</option>
))}
</select>
</div>
)}
{/* Key preview selector + collapse toggle */}
<div className="flex items-center gap-2 shrink-0">
{apiKeys.length > 0 && (
<>
<label className="text-xs text-text-muted whitespace-nowrap">
{t("previewForKey")}
</label>
<select
value={selectedKeyId}
onChange={(e) => 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]"
>
<option value="">{t("previewKeyNone")}</option>
{apiKeys.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k)}
</option>
))}
</select>
</>
)}
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? t("endpointsExpand") : t("endpointsCollapse")}
aria-label={collapsed ? t("endpointsExpand") : t("endpointsCollapse")}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors cursor-pointer"
>
<span className="material-symbols-outlined text-[18px]">
{collapsed ? "expand_more" : "expand_less"}
</span>
</button>
</div>
</div>
{/* Base URL line */}
<div className="mt-3 flex items-center gap-2 rounded-md bg-bg-subtle/50 border border-border/40 px-3 py-2">
<span className="text-[10px] uppercase tracking-wide text-text-muted font-semibold shrink-0">
{t("endpointsBaseUrl")}
</span>
<code className="text-xs text-primary font-mono">POST /v1/chat/completions</code>
<span className="text-xs text-text-muted mx-1">·</span>
<code className="text-xs text-text-muted font-mono">
model: &quot;qtSd/&lt;group&gt;/&lt;provider&gt;/&lt;model&gt;&quot;
</code>
{!collapsed && (
<>
{/* Base URL line(s) */}
<div className="mt-3 rounded-md bg-bg-subtle/50 border border-border/40 px-3 py-2 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-wide text-text-muted font-semibold shrink-0">
{t("endpointsBaseUrl")}
</span>
<code className="text-xs text-primary font-mono">POST /v1/chat/completions</code>
<span className="text-xs text-text-muted mx-1">·</span>
<code className="text-xs text-text-muted font-mono">
model: &quot;qtSd/&lt;group&gt;/&lt;provider&gt;/&lt;model&gt;&quot;
</code>
</div>
{hasAnthropic && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-wide text-text-muted font-semibold shrink-0">
{t("endpointsBaseUrl")}
</span>
<code className="text-xs text-primary font-mono">POST /v1/messages</code>
<span className="text-xs text-text-muted mx-1">·</span>
<code className="text-xs text-text-muted font-mono">
model: &quot;qtSd/&lt;group&gt;/&lt;provider&gt;/&lt;model&gt;&quot;
</code>
<span className="text-[10px] text-text-muted">({t("endpointsAnthropicNote")})</span>
</div>
)}
</div>
{/* Model listing */}
@@ -284,6 +331,8 @@ export default function QuotaEndpointsCard({
</div>
)}
</div>
</>
)}
</Card>
);
}

View File

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

View File

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

View File

@@ -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<string, string>;
};
const pt = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/pt-BR.json"), "utf8")) as {
quotaShare: Record<string, string>;
};
// ── 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`);
}
});