feat(ui): i18n for virtual keys table

This commit is contained in:
吹雪 2026-06-11 07:18:33 +09:00
parent c8b27b6d52
commit ebe6cb92f5
3 changed files with 153 additions and 60 deletions

View file

@ -3,6 +3,7 @@ import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
import { useTranslation } from "react-i18next";
import {
ColumnDef,
flexRender,
@ -53,6 +54,7 @@ interface VirtualKeysTableProps {
*/
export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) {
const { t } = useTranslation();
const { data: fetchedOrganizations } = useOrganizations();
const resolvedOrganizations = fetchedOrganizations ?? organizations ?? [];
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
@ -154,7 +156,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "token",
accessorKey: "token",
header: "Key ID",
header: t("virtualKeys.virtualKeysTable.keyId"),
size: 100,
enableSorting: true,
cell: (info) => {
@ -178,7 +180,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "key_alias",
accessorKey: "key_alias",
header: "Key Alias",
header: t("virtualKeys.virtualKeysTable.keyAlias"),
size: 150,
enableSorting: true,
cell: (info) => {
@ -193,7 +195,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
},
{
id: "status",
header: "Status",
header: t("common.status"),
size: 100,
enableSorting: false,
cell: ({ row }) => {
@ -201,18 +203,18 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
if (key.blocked !== true) {
return (
<Tag color="green" data-testid={`key-status-${key.token_id}`}>
Active
{t("common.active")}
</Tag>
);
}
const isScimBlocked = (key.metadata as Record<string, unknown> | null | undefined)?.scim_blocked === true;
const reason = isScimBlocked
? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)."
: "Blocked. Requests using this key will be rejected with 401.";
? t("virtualKeys.virtualKeysTable.blockedByScimReason")
: t("virtualKeys.virtualKeysTable.blockedReason");
return (
<Tooltip title={reason}>
<Tag color="red" data-testid={`key-status-${key.token_id}`}>
Blocked
{t("virtualKeys.virtualKeysTable.blocked")}
</Tag>
</Tooltip>
);
@ -221,7 +223,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "key_name",
accessorKey: "key_name",
header: "Secret Key",
header: t("virtualKeys.virtualKeysTable.secretKey"),
size: 120,
enableSorting: false,
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
@ -229,7 +231,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "team_alias",
accessorKey: "team_id",
header: "Team",
header: t("virtualKeys.virtualKeysTable.team"),
size: 120,
enableSorting: false,
cell: (info) => {
@ -248,7 +250,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "organization_alias",
accessorKey: "org_id",
header: "Organization",
header: t("virtualKeys.virtualKeysTable.organization"),
size: 140,
enableSorting: false,
cell: (info) => {
@ -269,8 +271,8 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
accessorKey: "user",
header: () => (
<span className="flex items-center gap-1">
User
<Popover content="Displays the first available value: User Alias, User Email, or User ID." trigger="hover">
{t("virtualKeys.virtualKeysTable.user")}
<Popover content={t("virtualKeys.virtualKeysTable.userPopoverHint")} trigger="hover">
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
@ -289,9 +291,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias },
{ label: "User Email", value: userEmail },
{ label: "User ID", value: userId },
{ label: t("virtualKeys.virtualKeysTable.userAlias"), value: userAlias },
{ label: t("virtualKeys.virtualKeysTable.userEmail"), value: userEmail },
{ label: t("virtualKeys.virtualKeysTable.userId"), value: userId },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
@ -332,7 +334,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "created_at",
accessorKey: "created_at",
header: "Created At",
header: t("common.createdAt"),
size: 120,
enableSorting: true,
cell: (info) => {
@ -343,7 +345,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "created_by",
accessorKey: "created_by",
header: "Created By",
header: t("virtualKeys.virtualKeysTable.createdBy"),
size: 160,
enableSorting: false,
cell: (info) => {
@ -360,9 +362,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
const popoverContent = (
<div className="flex flex-col gap-2 text-xs min-w-[200px] max-w-[300px]">
{[
{ label: "User Alias", value: userAlias },
{ label: "User Email", value: userEmail },
{ label: "User ID", value: userId },
{ label: t("virtualKeys.virtualKeysTable.userAlias"), value: userAlias },
{ label: t("virtualKeys.virtualKeysTable.userEmail"), value: userEmail },
{ label: t("virtualKeys.virtualKeysTable.userId"), value: userId },
].map(({ label, value }) => (
<div key={label} className="flex flex-col min-w-0">
<span className="text-gray-400">{label}</span>
@ -403,12 +405,12 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "updated_at",
accessorKey: "updated_at",
header: "Updated At",
header: t("common.updatedAt"),
size: 120,
enableSorting: true,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
return value ? new Date(value as string).toLocaleDateString() : t("common.never");
},
},
{
@ -416,11 +418,8 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
accessorKey: "last_active",
header: () => (
<span className="flex items-center gap-1">
Last Active
<Popover
content="This is a new field and is not backfilled. Only new key usage will update this value."
trigger="hover"
>
{t("virtualKeys.virtualKeysTable.lastActive")}
<Popover content={t("virtualKeys.virtualKeysTable.lastActiveHint")} trigger="hover">
<InfoCircleOutlined className="text-gray-400 text-xs cursor-help" />
</Popover>
</span>
@ -429,7 +428,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
enableSorting: false,
cell: (info) => {
const value = info.getValue();
if (!value) return "Unknown";
if (!value) return t("common.unknown");
const date = new Date(value as string);
return (
<Tooltip title={date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "long" })}>
@ -441,18 +440,18 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "expires",
accessorKey: "expires",
header: "Expires",
header: t("virtualKeys.virtualKeysTable.expires"),
size: 120,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleDateString() : "Never";
return value ? new Date(value as string).toLocaleDateString() : t("common.never");
},
},
{
id: "spend",
accessorKey: "spend",
header: "Spend (USD)",
header: t("virtualKeys.virtualKeysTable.spendUsd"),
size: 100,
enableSorting: true,
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
@ -460,13 +459,13 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "max_budget",
accessorKey: "max_budget",
header: "Budget (USD)",
header: t("virtualKeys.virtualKeysTable.budgetUsd"),
size: 110,
enableSorting: true,
cell: (info) => {
const maxBudget = info.getValue() as number | null;
if (maxBudget === null) {
return "Unlimited";
return t("virtualKeys.virtualKeysTable.unlimited");
}
return `$${formatNumberWithCommas(maxBudget)}`;
},
@ -474,18 +473,18 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{
id: "budget_reset_at",
accessorKey: "budget_reset_at",
header: "Budget Reset",
header: t("virtualKeys.virtualKeysTable.budgetReset"),
size: 130,
enableSorting: false,
cell: (info) => {
const value = info.getValue();
return value ? new Date(value as string).toLocaleString() : "Never";
return value ? new Date(value as string).toLocaleString() : t("common.never");
},
},
{
id: "models",
accessorKey: "models",
header: "Models",
header: t("virtualKeys.virtualKeysTable.models"),
size: 200,
enableSorting: false,
cell: (info) => {
@ -496,7 +495,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
<div className="flex flex-col">
{models.length === 0 ? (
<Badge size={"xs"} className="mb-1" color="red">
<Text>All Proxy Models</Text>
<Text>{t("virtualKeys.virtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<>
@ -520,7 +519,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{models.slice(0, 3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index} size={"xs"} color="red">
<Text>All Proxy Models</Text>
<Text>{t("virtualKeys.virtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<Badge key={index} size={"xs"} color="blue">
@ -534,9 +533,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
)}
{models.length > 3 && !expandedAccordions[info.row.id] && (
<Badge size={"xs"} color="gray" className="cursor-pointer">
<Text>
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
</Text>
<Text>{t("virtualKeys.virtualKeysTable.moreModel", { count: models.length - 3 })}</Text>
</Badge>
)}
{expandedAccordions[info.row.id] && (
@ -544,7 +541,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
{models.slice(3).map((model, index) =>
model === "all-proxy-models" ? (
<Badge key={index + 3} size={"xs"} color="red">
<Text>All Proxy Models</Text>
<Text>{t("virtualKeys.virtualKeysTable.allProxyModels")}</Text>
</Badge>
) : (
<Badge key={index + 3} size={"xs"} color="blue">
@ -570,27 +567,35 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
},
{
id: "rate_limits",
header: "Rate Limits",
header: t("virtualKeys.virtualKeysTable.rateLimits"),
size: 140,
enableSorting: false,
cell: ({ row }) => {
const key = row.original;
return (
<div>
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
<div>
{t("virtualKeys.virtualKeysTable.tpmLimit", {
value: key.tpm_limit !== null ? key.tpm_limit : t("virtualKeys.virtualKeysTable.unlimited"),
})}
</div>
<div>
{t("virtualKeys.virtualKeysTable.rpmLimit", {
value: key.rpm_limit !== null ? key.rpm_limit : t("virtualKeys.virtualKeysTable.unlimited"),
})}
</div>
</div>
);
},
},
],
[teams, resolvedOrganizations],
[teams, resolvedOrganizations, t],
);
const filterOptions: FilterOption[] = [
{
name: "Team ID",
label: "Team ID",
label: t("virtualKeys.virtualKeysTable.filterTeamId"),
isSearchable: true,
searchFn: async (searchText: string) => {
if (!allTeams || allTeams.length === 0) return [];
@ -609,7 +614,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
},
{
name: "Organization ID",
label: "Organization ID",
label: t("virtualKeys.virtualKeysTable.filterOrgId"),
isSearchable: true,
searchFn: async (searchText: string) => {
if (!allOrganizations || allOrganizations.length === 0) return [];
@ -621,24 +626,24 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
return filteredOrgs
.filter((org) => org.organization_id !== null && org.organization_id !== undefined)
.map((org) => ({
label: `${org.organization_id || "Unknown"} (${org.organization_id})`,
label: `${org.organization_id || t("common.unknown")} (${org.organization_id})`,
value: org.organization_id as string,
}));
},
},
{
name: "Key Alias",
label: "Key Alias",
label: t("virtualKeys.virtualKeysTable.filterKeyAlias"),
customComponent: PaginatedKeyAliasSelect,
},
{
name: "User ID",
label: "User ID",
label: t("virtualKeys.virtualKeysTable.filterUserId"),
isSearchable: false,
},
{
name: "Key Hash",
label: "Key Hash",
label: t("virtualKeys.virtualKeysTable.filterKeyHash"),
isSearchable: false,
},
];
@ -725,7 +730,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
<Skeleton.Node active style={{ width: 200, height: 20 }} />
) : (
<span className="inline-flex text-sm text-gray-700">
Showing {rangeLabel} of {totalCount} results
{t("virtualKeys.virtualKeysTable.showingResults", { range: rangeLabel, total: totalCount })}
</span>
)}
@ -734,9 +739,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
icon={<SyncOutlined spin={isButtonLoading} />}
onClick={handleRefresh}
disabled={isButtonLoading}
title="Fetch data"
title={t("virtualKeys.virtualKeysTable.fetchData")}
>
{isButtonLoading ? "Fetching" : "Fetch"}
{isButtonLoading ? t("virtualKeys.virtualKeysTable.fetching") : t("virtualKeys.virtualKeysTable.fetch")}
</AntButton>
</div>
@ -745,7 +750,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
<Skeleton.Node active style={{ width: 74, height: 20 }} />
) : (
<span className="text-sm text-gray-700">
Page {pageIndex + 1} of {table.getPageCount()}
{t("virtualKeys.virtualKeysTable.pageOf", { page: pageIndex + 1, total: table.getPageCount() })}
</span>
)}
@ -757,7 +762,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
disabled={isLoading || !table.getCanPreviousPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
{t("common.previous")}
</button>
)}
@ -769,7 +774,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
disabled={isLoading || !table.getCanNextPage()}
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
{t("common.next")}
</button>
)}
</div>
@ -856,7 +861,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>🚅 Loading keys...</p>
<p>{t("virtualKeys.virtualKeysTable.loadingKeys")}</p>
</div>
</TableCell>
</TableRow>
@ -883,7 +888,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No keys found</p>
<p>{t("virtualKeys.virtualKeysTable.noKeysFound")}</p>
</div>
</TableCell>
</TableRow>

View file

@ -8316,5 +8316,49 @@
"notSet": "Not Set",
"updateSettings": "Update Settings"
}
},
"virtualKeys": {
"virtualKeysTable": {
"keyId": "Key ID",
"keyAlias": "Key Alias",
"secretKey": "Secret Key",
"team": "Team",
"organization": "Organization",
"user": "User",
"createdBy": "Created By",
"lastActive": "Last Active",
"expires": "Expires",
"spendUsd": "Spend (USD)",
"budgetUsd": "Budget (USD)",
"budgetReset": "Budget Reset",
"models": "Models",
"rateLimits": "Rate Limits",
"blocked": "Blocked",
"blockedByScimReason": "Blocked by SCIM (external identity provider deactivated or deleted the owning user).",
"blockedReason": "Blocked. Requests using this key will be rejected with 401.",
"userPopoverHint": "Displays the first available value: User Alias, User Email, or User ID.",
"userAlias": "User Alias",
"userEmail": "User Email",
"userId": "User ID",
"allProxyModels": "All Proxy Models",
"moreModel_one": "+{{count}} more model",
"moreModel_other": "+{{count}} more models",
"tpmLimit": "TPM: {{value}}",
"rpmLimit": "RPM: {{value}}",
"unlimited": "Unlimited",
"lastActiveHint": "This is a new field and is not backfilled. Only new key usage will update this value.",
"loadingKeys": "🚅 Loading keys...",
"noKeysFound": "No keys found",
"showingResults": "Showing {{range}} of {{total}} results",
"fetchData": "Fetch data",
"fetching": "Fetching",
"fetch": "Fetch",
"pageOf": "Page {{page}} of {{total}}",
"filterTeamId": "Team ID",
"filterOrgId": "Organization ID",
"filterKeyAlias": "Key Alias",
"filterUserId": "User ID",
"filterKeyHash": "Key Hash"
}
}
}

View file

@ -8316,5 +8316,49 @@
"notSet": "未设置",
"updateSettings": "更新设置"
}
},
"virtualKeys": {
"virtualKeysTable": {
"keyId": "密钥 ID",
"keyAlias": "密钥别名",
"secretKey": "密钥",
"team": "团队",
"organization": "组织",
"user": "用户",
"createdBy": "创建者",
"lastActive": "最后活跃",
"expires": "过期时间",
"spendUsd": "花费(USD)",
"budgetUsd": "预算(USD)",
"budgetReset": "预算重置",
"models": "模型",
"rateLimits": "速率限制",
"blocked": "已封禁",
"blockedByScimReason": "被 SCIM 封禁(外部身份提供商已停用或删除了该密钥所属用户)。",
"blockedReason": "已封禁。使用此密钥的请求将以 401 被拒绝。",
"userPopoverHint": "按优先顺序显示:用户别名、用户邮箱或用户 ID。",
"userAlias": "用户别名",
"userEmail": "用户邮箱",
"userId": "用户 ID",
"allProxyModels": "所有代理模型",
"moreModel_one": "+{{count}} 个模型",
"moreModel_other": "+{{count}} 个模型",
"tpmLimit": "TPM: {{value}}",
"rpmLimit": "RPM: {{value}}",
"unlimited": "无限制",
"lastActiveHint": "这是一个新字段,未回填历史数据。仅新的密钥使用记录会更新此值。",
"loadingKeys": "🚅 正在加载密钥...",
"noKeysFound": "未找到密钥",
"showingResults": "显示第 {{range}} 条,共 {{total}} 条",
"fetchData": "拉取数据",
"fetching": "拉取中",
"fetch": "拉取",
"pageOf": "第 {{page}} 页,共 {{total}} 页",
"filterTeamId": "团队 ID",
"filterOrgId": "组织 ID",
"filterKeyAlias": "密钥别名",
"filterUserId": "用户 ID",
"filterKeyHash": "密钥哈希"
}
}
}