diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 621cbcb183..0a3202e6bc 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -102,6 +102,7 @@ interface ApiKey { allowedEndpoints?: string[]; streamDefaultMode?: StreamDefaultMode; disableNonPublicModels?: boolean; + allowedQuotas?: string[] | null; createdAt: string; } @@ -163,6 +164,7 @@ export default function ApiManagerPageClient() { const [activeOnly, setActiveOnly] = useState(false); const [statusFilter, setStatusFilter] = useState(null); const [typeFilter, setTypeFilter] = useState(null); + const [quotaPoolGroup, setQuotaPoolGroup] = useState>({}); const { copied, copy } = useCopyToClipboard(); @@ -199,6 +201,44 @@ export default function ApiManagerPageClient() { writeActiveOnlyPreference(activeOnly); }, [activeOnly]); + useEffect(() => { + let cancelled = false; + const loadQuotaGroups = async () => { + try { + const [poolsRes, groupsRes] = await Promise.all([ + fetch("/api/quota/pools"), + fetch("/api/quota/groups"), + ]); + if (!poolsRes.ok || !groupsRes.ok) return; + const poolsData = await poolsRes.json(); + const groupsData = await groupsRes.json(); + const pools: Array<{ id: string; groupId: string }> = Array.isArray(poolsData.pools) + ? poolsData.pools + : []; + const groups: Array<{ id: string; name: string }> = Array.isArray(groupsData.groups) + ? groupsData.groups + : []; + const groupNameById: Record = {}; + for (const g of groups) { + groupNameById[g.id] = g.name; + } + const map: Record = {}; + for (const p of pools) { + if (groupNameById[p.groupId]) { + map[p.id] = groupNameById[p.groupId]; + } + } + if (!cancelled) setQuotaPoolGroup(map); + } catch { + // fail open — quota group chips simply won't render + } + }; + loadQuotaGroups(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!showAddModal || !nameError) return; @@ -370,6 +410,26 @@ export default function ApiManagerPageClient() { const isFiltered = activeOnly || statusFilter !== null || typeFilter !== null || searchQuery.trim() !== ""; + const isQuotaKey = (k: ApiKey) => + Array.isArray(k.allowedQuotas) && k.allowedQuotas.length > 0; + + const quotaKeys = filteredKeys.filter(isQuotaKey); + const normalKeys = filteredKeys.filter((k) => !isQuotaKey(k)); + + const quotaGroupsForKey = (k: ApiKey): string[] => { + if (!Array.isArray(k.allowedQuotas)) return []; + const seen = new Set(); + const result: string[] = []; + for (const poolId of k.allowedQuotas) { + const groupName = quotaPoolGroup[poolId]; + if (groupName && !seen.has(groupName)) { + seen.add(groupName); + result.push(groupName); + } + } + return result; + }; + const handleClearFilters = () => { setSearchQuery(""); setActiveOnly(false); @@ -804,21 +864,11 @@ export default function ApiManagerPageClient() { ) : ( -
- {/* Table Header */} -
-
{t("name")}
-
{t("key")}
-
{t("permissions")}
-
{t("usage")}
-
{t("created")}
-
{t("actions")}
-
- - {/* Table Rows */} - {filteredKeys.map((key) => { + (() => { + const renderKeyRow = (key: ApiKey) => { const stats = usageStats[key.id]; - const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; + const isRestricted = + Array.isArray(key.allowedModels) && key.allowedModels.length > 0; const hasComboRestrictions = Array.isArray(key.allowedCombos) && key.allowedCombos.length > 0; const hasConnectionRestrictions = @@ -830,12 +880,17 @@ export default function ApiManagerPageClient() { ? key.throttleDelayMs : 0; const hasThrottle = throttleDelayMs > 0; - const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage"); + const hasManageScope = + Array.isArray(key.scopes) && key.scopes.includes("manage"); const hasJsonStreamDefault = key.streamDefaultMode === "json"; const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; const activeSessions = sessionCounts[key.id] || 0; const hasSchedule = key.accessSchedule?.enabled === true; + const keyIsQuota = isQuotaKey(key); + const groups = quotaGroupsForKey(key); + const visibleGroups = groups.slice(0, 3); + const extraGroupCount = groups.length - visibleGroups.length; return (
+ {/* QUOTA differentiation chips — prepended before existing badges */} + {keyIsQuota && ( + + {t("quotaModeOnly")} + + )} + {keyIsQuota && + visibleGroups.map((groupName) => ( + + {groupName} + + ))} + {keyIsQuota && extraGroupCount > 0 && ( + + +{extraGroupCount} + + )} + {/* Existing badges */} {isRestricted ? ( ) : ( )} {hasComboRestrictions && ( @@ -907,7 +983,7 @@ export default function ApiManagerPageClient() { className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-teal-500/10 text-teal-600 dark:text-teal-400 text-xs font-medium hover:bg-teal-500/20 transition-colors" > hub - {key.allowedCombos.length} combos + {key.allowedCombos!.length} combos )} {noLogEnabled && ( @@ -1019,8 +1095,67 @@ export default function ApiManagerPageClient() {
); - })} -
+ }; + + const tableHeader = ( +
+
{t("name")}
+
{t("key")}
+
{t("permissions")}
+
{t("usage")}
+
{t("created")}
+
{t("actions")}
+
+ ); + + return ( +
+ {normalKeys.length > 0 && ( +
+ {/* Normal keys section heading */} +
+ + vpn_key + + + {t("normalKeysSection")} + + + {normalKeys.length} + +
+
+ {tableHeader} + {normalKeys.map(renderKeyRow)} +
+
+ )} + {quotaKeys.length > 0 && ( +
+ {/* Quota keys section heading */} +
+ + toll + + + {t("quotaKeysSection")} + + + {quotaKeys.length} + + + {t("quotaPill")} + +
+
+ {tableHeader} + {quotaKeys.map(renderKeyRow)} +
+
+ )} +
+ ); + })() )} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 46314b5e7d..fe7e77c8c7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1764,7 +1764,11 @@ "emptyFilterTitle": "No keys match your filters", "emptyFilterClear": "Clear filters", "disableNonPublicModels": "Disable Non-Public Models", - "disableNonPublicModelsDesc": "Reject requests for models that are not discovered or not marked as public in the provider catalog" + "disableNonPublicModelsDesc": "Reject requests for models that are not discovered or not marked as public in the provider catalog", + "normalKeysSection": "Normal keys", + "quotaKeysSection": "Quota keys", + "quotaPill": "QUOTA", + "quotaModeOnly": "qtSd-only" }, "auditLog": { "title": "Audit Log", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index dbf4e3d763..948f2f2d39 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -437,7 +437,11 @@ "filterTypeRestricted": "Restrita", "shownOf": "{shown} de {total} exibidas", "emptyFilterTitle": "Nenhuma chave corresponde aos filtros", - "emptyFilterClear": "Limpar filtros" + "emptyFilterClear": "Limpar filtros", + "normalKeysSection": "Chaves normais", + "quotaKeysSection": "Chaves de cota", + "quotaPill": "QUOTA", + "quotaModeOnly": "só-qtSd" }, "auditLog": { "title": "Log de Auditoria", diff --git a/tests/unit/api-manager-quota-keys-section.test.ts b/tests/unit/api-manager-quota-keys-section.test.ts new file mode 100644 index 0000000000..b6c5febba7 --- /dev/null +++ b/tests/unit/api-manager-quota-keys-section.test.ts @@ -0,0 +1,58 @@ +/** + * tests/unit/api-manager-quota-keys-section.test.ts + * + * Source-level assertions for the API Manager "two separate tables" layout: + * quota keys (allowedQuotas non-empty) render in their own section, visually + * differentiated from normal keys (QUOTA pill + group chips + qtSd-only mode). + * Pattern mirrors api-manager-page-static.test.ts (source-scan + i18n parity). + */ + +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 PAGE = join( + ROOT, + "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx" +); +const src = readFileSync(PAGE, "utf8"); +const en = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/en.json"), "utf8")) as { + apiManager: Record; +}; +const pt = JSON.parse(readFileSync(join(ROOT, "src/i18n/messages/pt-BR.json"), "utf8")) as { + apiManager: Record; +}; + +test("api-manager splits keys into normal + quota sections", () => { + assert.ok(src.includes("const isQuotaKey"), "must classify quota keys by allowedQuotas"); + assert.ok( + src.includes("allowedQuotas") && /allowedQuotas\.length\s*>\s*0/.test(src), + "quota key = allowedQuotas non-empty" + ); + assert.ok(src.includes("const quotaKeys") && src.includes("const normalKeys"), "must split the two arrays"); + assert.ok(src.includes("normalKeys.map(renderKeyRow)"), "normal section renders rows"); + assert.ok(src.includes("quotaKeys.map(renderKeyRow)"), "quota section renders rows"); +}); + +test("api-manager differentiates quota keys (pill + groups + mode)", () => { + assert.ok(src.includes('t("quotaPill")'), "quota section must show the QUOTA pill"); + assert.ok(src.includes('t("quotaModeOnly")'), "quota rows must show the qtSd-only mode chip"); + assert.ok( + src.includes("quotaGroupsForKey") && src.includes("quotaPoolGroup"), + "must map a quota key's pools to group names for the chips" + ); + assert.ok( + src.includes("/api/quota/pools") && src.includes("/api/quota/groups"), + "must fetch pools + groups to resolve group names" + ); +}); + +test("api-manager: new i18n keys exist in both en and pt-BR", () => { + for (const k of ["normalKeysSection", "quotaKeysSection", "quotaPill", "quotaModeOnly"]) { + assert.ok(en.apiManager[k], `en apiManager.${k}`); + assert.ok(pt.apiManager[k], `pt-BR apiManager.${k}`); + } +});