feat(api-manager): split keys into Normal vs Quota sections (compact 2-table layout)

The key list stacked many badges in one column (tall/cluttered) and didn't
distinguish quota keys. Now renders two sections — "Normal keys" and "Quota keys"
(purple QUOTA pill) — sharing the same compact table header via an extracted
renderKeyRow(). Quota rows prepend a qtSd-only mode chip + group-name chips
(resolved by fetching /api/quota/pools + /api/quota/groups → poolId→group map).
Empty sections are hidden. i18n en/pt-BR for the new labels.

Source-scan test + i18n parity in api-manager-quota-keys-section.test.ts.
This commit is contained in:
diegosouzapw
2026-06-02 07:06:00 -03:00
parent 8086d2878b
commit 8277b98003
4 changed files with 223 additions and 22 deletions

View File

@@ -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<KeyStatus | null>(null);
const [typeFilter, setTypeFilter] = useState<KeyType | null>(null);
const [quotaPoolGroup, setQuotaPoolGroup] = useState<Record<string, string>>({});
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<string, string> = {};
for (const g of groups) {
groupNameById[g.id] = g.name;
}
const map: Record<string, string> = {};
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<string>();
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() {
<Button onClick={handleClearFilters}>{t("emptyFilterClear")}</Button>
</div>
) : (
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
{/* Table Header */}
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-surface/50 border-b border-border text-xs font-semibold text-text-muted uppercase tracking-wider">
<div className="col-span-2">{t("name")}</div>
<div className="col-span-3">{t("key")}</div>
<div className="col-span-2">{t("permissions")}</div>
<div className="col-span-2">{t("usage")}</div>
<div className="col-span-1">{t("created")}</div>
<div className="col-span-2 text-right">{t("actions")}</div>
</div>
{/* 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 (
<div
key={key.id}
@@ -875,13 +930,34 @@ export default function ApiManagerPageClient() {
</div>
<div className="col-span-2 flex items-center">
<div className="flex flex-col items-start gap-1">
{/* QUOTA differentiation chips — prepended before existing badges */}
{keyIsQuota && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
{t("quotaModeOnly")}
</span>
)}
{keyIsQuota &&
visibleGroups.map((groupName) => (
<span
key={groupName}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium truncate max-w-full"
>
{groupName}
</span>
))}
{keyIsQuota && extraGroupCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[11px] font-medium">
+{extraGroupCount}
</span>
)}
{/* Existing badges */}
{isRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{t("modelsCount", { count: key.allowedModels.length })}
{t("modelsCount", { count: key.allowedModels!.length })}
</button>
) : (
<button
@@ -898,7 +974,7 @@ export default function ApiManagerPageClient() {
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">cable</span>
{key.allowedConnections.length} conn
{key.allowedConnections!.length} conn
</button>
)}
{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"
>
<span className="material-symbols-outlined text-[14px]">hub</span>
{key.allowedCombos.length} combos
{key.allowedCombos!.length} combos
</button>
)}
{noLogEnabled && (
@@ -1019,8 +1095,67 @@ export default function ApiManagerPageClient() {
</div>
</div>
);
})}
</div>
};
const tableHeader = (
<div className="grid grid-cols-12 gap-4 px-4 py-3 bg-surface/50 border-b border-border text-xs font-semibold text-text-muted uppercase tracking-wider">
<div className="col-span-2">{t("name")}</div>
<div className="col-span-3">{t("key")}</div>
<div className="col-span-2">{t("permissions")}</div>
<div className="col-span-2">{t("usage")}</div>
<div className="col-span-1">{t("created")}</div>
<div className="col-span-2 text-right">{t("actions")}</div>
</div>
);
return (
<div className="flex flex-col gap-4">
{normalKeys.length > 0 && (
<div>
{/* Normal keys section heading */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-base text-text-muted">
vpn_key
</span>
<span className="text-sm font-medium text-text-main">
{t("normalKeysSection")}
</span>
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-surface/80 border border-border text-[11px] font-semibold text-text-muted">
{normalKeys.length}
</span>
</div>
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
{tableHeader}
{normalKeys.map(renderKeyRow)}
</div>
</div>
)}
{quotaKeys.length > 0 && (
<div>
{/* Quota keys section heading */}
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-base text-violet-500">
toll
</span>
<span className="text-sm font-medium text-text-main">
{t("quotaKeysSection")}
</span>
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-surface/80 border border-border text-[11px] font-semibold text-text-muted">
{quotaKeys.length}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-semibold">
{t("quotaPill")}
</span>
</div>
<div className="flex flex-col border border-border rounded-lg overflow-hidden">
{tableHeader}
{quotaKeys.map(renderKeyRow)}
</div>
</div>
)}
</div>
);
})()
)}
</Card>

View File

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

View File

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

View File

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