mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
58
tests/unit/api-manager-quota-keys-section.test.ts
Normal file
58
tests/unit/api-manager-quota-keys-section.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user