feat(ui): i18n for tool policies, UI access control, and usage indicator

This commit is contained in:
吹雪 2026-06-10 12:53:03 +09:00
parent 2980302d96
commit 1746865a7b
5 changed files with 325 additions and 117 deletions

View file

@ -1,6 +1,7 @@
"use client";
import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Switch, Tooltip } from "antd";
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react";
import { TimeCell } from "./view_logs/time_cell";
@ -29,11 +30,15 @@ function countToolsInUTCDay(tools: ToolRow[], utcDateKey: string): number {
return tools.filter((t) => isCreatedInUTCDay(t.created_at, utcDateKey)).length;
}
function getTrendSubtitle(newToday: number, newYesterday: number): string | undefined {
function getTrendSubtitle(
newToday: number,
newYesterday: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): string | undefined {
const diff = newToday - newYesterday;
if (diff === 0) return undefined;
if (diff > 0) return `+${diff} since yesterday`;
return `${diff} since yesterday`;
if (diff > 0) return t("toolPolicies.trendPositive", { diff });
return t("toolPolicies.trendNegative", { diff });
}
type SortField = "tool_name" | "input_policy" | "output_policy" | "team_id" | "key_alias" | "created_at" | "call_count";
@ -49,6 +54,7 @@ interface ToolPoliciesProps {
}
export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelectTool }) => {
const { t } = useTranslation();
const [tools, setTools] = useState<ToolRow[]>([]);
const [loading, setLoading] = useState(true);
const [isFetching, setIsFetching] = useState(false);
@ -75,12 +81,12 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
const rows = await fetchToolsList(accessToken);
setTools(rows);
} catch (e: any) {
setError(e.message ?? "Failed to load tools");
setError(e.message ?? t("toolPolicies.failedToLoadTools"));
} finally {
setIsFetching(false);
setLoading(false);
}
}, [accessToken]);
}, [accessToken, t]);
useEffect(() => {
load();
@ -99,7 +105,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
await updateToolPolicy(accessToken, toolName, { input_policy: newPolicy });
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, input_policy: newPolicy } : t)));
} catch (e: any) {
alert(`Failed to update input policy: ${e.message}`);
alert(t("toolPolicies.updateInputPolicyFailed", { error: e.message }));
} finally {
setSavingInput(null);
}
@ -112,7 +118,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
await updateToolPolicy(accessToken, toolName, { output_policy: newPolicy });
setTools((prev) => prev.map((t) => (t.tool_name === toolName ? { ...t, output_policy: newPolicy } : t)));
} catch (e: any) {
alert(`Failed to update output policy: ${e.message}`);
alert(t("toolPolicies.updateOutputPolicyFailed", { error: e.message }));
} finally {
setSavingOutput(null);
}
@ -151,22 +157,22 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
const filterOptions: FilterOption[] = [
{
name: "Input Policy",
label: "Input Policy",
label: t("toolPolicies.inputPolicy"),
options: INPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })),
},
{
name: "Output Policy",
label: "Output Policy",
label: t("toolPolicies.outputPolicy"),
options: OUTPUT_POLICY_OPTIONS.map((o) => ({ label: o.label, value: o.value })),
},
{
name: "Team Name",
label: "Team Name",
label: t("toolPolicies.teamName"),
options: teamOptions,
},
{
name: "Key Name",
label: "Key Name",
label: t("toolPolicies.keyName"),
options: keyAliasOptions,
},
];
@ -181,7 +187,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
const newToday = countToolsInUTCDay(tools, todayKey);
const newYesterday = countToolsInUTCDay(tools, yesterdayKey);
const trendSubtitle = getTrendSubtitle(newToday, newYesterday);
const trendSubtitle = getTrendSubtitle(newToday, newYesterday, t);
const totalTools = tools.length;
const blockedCount = tools.filter((t) => t.input_policy === "blocked").length;
@ -200,7 +206,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
activeTeamsCount,
needsReviewTools,
};
}, [tools]);
}, [tools, t]);
const SortHeader = ({ label, field }: { label: string; field: SortField }) => (
<div className="flex items-center gap-1">
@ -257,11 +263,11 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
return (
<div className="w-full">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Tool Policies</h1>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">{t("toolPolicies.title")}</h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
label="New Today"
label={t("toolPolicies.newToday")}
value={newToday}
valueColor="text-green-600"
subtitle={trendSubtitle}
@ -271,37 +277,36 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
</svg>
}
/>
<MetricCard label="Total Tools Discovered" value={totalTools} />
<MetricCard label={t("toolPolicies.totalToolsDiscovered")} value={totalTools} />
<MetricCard
label="Blocked Tools"
label={t("toolPolicies.blockedTools")}
value={blockedCount}
valueColor={blockedCount > 0 ? "text-red-600" : undefined}
/>
<MetricCard label="Active Teams" value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
<MetricCard label={t("toolPolicies.activeTeams")} value={activeTeamsCount > 0 ? activeTeamsCount : "—"} />
</div>
{needsReviewTools.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<h2 className="text-sm font-semibold text-amber-900 mb-1">Needs Review</h2>
<h2 className="text-sm font-semibold text-amber-900 mb-1">{t("toolPolicies.needsReview")}</h2>
<p className="text-sm text-amber-800 mb-3">
{needsReviewTools.length} new tool{needsReviewTools.length !== 1 ? "s" : ""} discovered that require policy
decisions.
{t("toolPolicies.needsReviewDesc", { count: needsReviewTools.length })}
</p>
<div className="flex flex-wrap gap-2">
{needsReviewTools.map((t) => (
{needsReviewTools.map((tool) => (
<span
key={t.tool_id}
key={tool.tool_id}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-white border border-amber-200 rounded-md text-sm"
>
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={t.tool_name}>
{t.tool_name}
<span className="font-mono text-amber-900 truncate max-w-[200px]" title={tool.tool_name}>
{tool.tool_name}
</span>
<button
type="button"
onClick={() => scrollToToolRow(t.tool_id)}
onClick={() => scrollToToolRow(tool.tool_id)}
className="text-amber-700 hover:text-amber-900 font-medium text-xs whitespace-nowrap"
>
Review
{t("toolPolicies.review")}
</button>
</span>
))}
@ -316,7 +321,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
<div className="relative w-64">
<input
type="text"
placeholder="Search by Tool Name"
placeholder={t("toolPolicies.searchByToolName")}
className="w-full px-3 py-2 pl-8 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={searchTerm}
onChange={(e) => {
@ -340,7 +345,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">Live Tail</span>
<span className="text-sm font-medium text-gray-900">{t("toolPolicies.liveTail")}</span>
<Switch checked={isLiveTail} onChange={setIsLiveTail} />
</div>
@ -362,32 +367,33 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{isButtonLoading ? "Fetching" : "Fetch"}
{isButtonLoading ? t("toolPolicies.fetching") : t("toolPolicies.fetch")}
</button>
</div>
<div className="flex items-center gap-4 text-sm text-gray-600 whitespace-nowrap">
<span>
Showing {filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1} -{" "}
{Math.min(currentPage * pageSize, filtered.length)} of {filtered.length} results
</span>
<span>
Page {currentPage} of {totalPages}
{t("toolPolicies.showing", {
from: filtered.length === 0 ? 0 : (currentPage - 1) * pageSize + 1,
to: Math.min(currentPage * pageSize, filtered.length),
total: filtered.length,
})}
</span>
<span>{t("toolPolicies.page", { current: currentPage, total: totalPages })}</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
>
Previous
{t("common.previous")}
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 border rounded-md text-sm hover:bg-gray-50 disabled:opacity-40"
>
Next
{t("common.next")}
</button>
</div>
</div>
@ -398,16 +404,16 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
options={filterOptions}
onApplyFilters={handleApplyFilters}
onResetFilters={handleResetFilters}
buttonLabel="Filters"
buttonLabel={t("toolPolicies.filtersButton")}
/>
</div>
</div>
{isLiveTail && (
<div className="bg-green-50 border-b border-green-100 px-6 py-2 flex items-center justify-between">
<span className="text-sm text-green-700">Auto-refreshing every 15 seconds</span>
<span className="text-sm text-green-700">{t("toolPolicies.autoRefreshing")}</span>
<button onClick={() => setIsLiveTail(false)} className="text-xs text-green-600 underline">
Stop
{t("toolPolicies.stop")}
</button>
</div>
)}
@ -420,41 +426,41 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
<TableHead>
<TableRow>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Discovered" field="created_at" />
<SortHeader label={t("toolPolicies.colDiscovered")} field="created_at" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Tool Name" field="tool_name" />
<SortHeader label={t("toolPolicies.colToolName")} field="tool_name" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Input Policy" field="input_policy" />
<SortHeader label={t("toolPolicies.inputPolicy")} field="input_policy" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Output Policy" field="output_policy" />
<SortHeader label={t("toolPolicies.outputPolicy")} field="output_policy" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="# Calls" field="call_count" />
<SortHeader label={t("toolPolicies.colCalls")} field="call_count" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Team Name" field="team_id" />
<SortHeader label={t("toolPolicies.teamName")} field="team_id" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Key Hash</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">{t("toolPolicies.colKeyHash")}</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">
<SortHeader label="Key Name" field="key_alias" />
<SortHeader label={t("toolPolicies.keyName")} field="key_alias" />
</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">User Agent</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">{t("toolPolicies.colUserAgent")}</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
Loading tools…
{t("toolPolicies.loadingTools")}
</TableCell>
</TableRow>
) : paginated.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="h-8 text-center text-gray-500">
No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery.
{t("toolPolicies.noToolsDiscovered")}
</TableCell>
</TableRow>
) : (
@ -469,7 +475,7 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
onClick={() => onSelectTool?.(tool.tool_name)}
className="text-left w-full font-mono text-xs max-w-[20ch] truncate block font-medium text-blue-600 hover:text-blue-800 hover:underline focus:outline-none focus:ring-0"
>
<Tooltip title={onSelectTool ? "Click to view details and block for team/key" : tool.tool_name}>
<Tooltip title={onSelectTool ? t("toolPolicies.clickToViewDetails") : tool.tool_name}>
<span>{tool.tool_name}</span>
</Tooltip>
</button>
@ -530,8 +536,11 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between text-sm text-gray-600">
<span>
Showing {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, sorted.length)} of{" "}
{sorted.length}
{t("toolPolicies.showing", {
from: (currentPage - 1) * pageSize + 1,
to: Math.min(currentPage * pageSize, sorted.length),
total: sorted.length,
})}
</span>
<div className="flex gap-1">
<button
@ -539,14 +548,14 @@ export const ToolPolicies: React.FC<ToolPoliciesProps> = ({ accessToken, onSelec
disabled={currentPage === 1}
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
>
Previous
{t("common.previous")}
</button>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 border rounded-md hover:bg-gray-50 disabled:opacity-40"
>
Next
{t("common.next")}
</button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Form, Button as Button2, Select } from "antd";
import { Text, TextInput } from "@tremor/react";
import { getSSOSettings, updateSSOSettings } from "./networking";
@ -11,6 +12,7 @@ interface UIAccessControlFormProps {
// Separate UI Access Control Form Component
const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken, onSuccess }) => {
const { t } = useTranslation();
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
@ -53,7 +55,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
const handleUIAccessSubmit = async (formValues: Record<string, any>) => {
if (!accessToken) {
NotificationManager.fromBackend("No access token available");
NotificationManager.fromBackend(t("uiAccessControlForm.noAccessToken"));
return;
}
@ -81,7 +83,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
onSuccess();
} catch (error) {
console.error("Failed to save UI access settings:", error);
NotificationManager.fromBackend("Failed to save UI access settings");
NotificationManager.fromBackend(t("uiAccessControlForm.saveSettingsFailed"));
} finally {
setLoading(false);
}
@ -90,16 +92,22 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
return (
<div style={{ padding: "16px" }}>
<div style={{ marginBottom: "16px" }}>
<Text style={{ fontSize: "14px", color: "#6b7280" }}>
Configure who can access the UI interface and how group information is extracted from JWT tokens.
</Text>
<Text style={{ fontSize: "14px", color: "#6b7280" }}>{t("uiAccessControlForm.description")}</Text>
</div>
<Form form={form} onFinish={handleUIAccessSubmit} layout="vertical">
<Form.Item label="UI Access Mode" name="ui_access_mode_type" tooltip="Controls who can access the UI interface">
<Select placeholder="Select access mode">
<Select.Option value="all_authenticated_users">All Authenticated Users</Select.Option>
<Select.Option value="restricted_sso_group">Restricted SSO Group</Select.Option>
<Form.Item
label={t("uiAccessControlForm.uiAccessModeLabel")}
name="ui_access_mode_type"
tooltip={t("uiAccessControlForm.uiAccessModeTooltip")}
>
<Select placeholder={t("uiAccessControlForm.selectAccessModePlaceholder")}>
<Select.Option value="all_authenticated_users">
{t("uiAccessControlForm.optionAllAuthenticatedUsers")}
</Select.Option>
<Select.Option value="restricted_sso_group">
{t("uiAccessControlForm.optionRestrictedSsoGroup")}
</Select.Option>
</Select>
</Form.Item>
@ -113,9 +121,9 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
const uiAccessModeType = getFieldValue("ui_access_mode_type");
return uiAccessModeType === "restricted_sso_group" ? (
<Form.Item
label="Restricted SSO Group"
label={t("uiAccessControlForm.restrictedSsoGroupLabel")}
name="restricted_sso_group"
rules={[{ required: true, message: "Please enter the restricted SSO group" }]}
rules={[{ required: true, message: t("uiAccessControlForm.restrictedSsoGroupRequired") }]}
>
<TextInput placeholder="ui-access-group" />
</Form.Item>
@ -124,9 +132,9 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
</Form.Item>
<Form.Item
label="SSO Group JWT Field"
label={t("uiAccessControlForm.ssoGroupJwtFieldLabel")}
name="sso_group_jwt_field"
tooltip="JWT field name that contains team/group information. Use dot notation to access nested fields."
tooltip={t("uiAccessControlForm.ssoGroupJwtFieldTooltip")}
>
<TextInput placeholder="groups" />
</Form.Item>
@ -141,7 +149,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
borderColor: "#6366f1",
}}
>
Update UI Access Control
{t("uiAccessControlForm.updateButton")}
</Button2>
</div>
</Form>

View file

@ -12,6 +12,7 @@ import {
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking";
// Simple utility function to combine class names
@ -44,19 +45,21 @@ const getDaysUntilExpiration = (expirationDate: string | null): number | null =>
return diffDays;
};
// Format expiration for display
const formatExpirationDisplay = (daysRemaining: number | null): string => {
if (daysRemaining === null) return "No expiration";
if (daysRemaining < 0) return "Expired";
if (daysRemaining === 0) return "Expires today";
if (daysRemaining === 1) return "1 day remaining";
if (daysRemaining < 30) return `${daysRemaining} days remaining`;
if (daysRemaining < 60) return "1 month remaining";
const formatExpirationDisplay = (
daysRemaining: number | null,
t: (key: string, opts?: Record<string, unknown>) => string,
): string => {
if (daysRemaining === null) return t("usageIndicator.noExpiration");
if (daysRemaining < 0) return t("usageIndicator.expired");
if (daysRemaining === 0) return t("usageIndicator.expiresToday");
if (daysRemaining < 30) return t("usageIndicator.daysRemaining", { count: daysRemaining });
if (daysRemaining < 60) return t("usageIndicator.oneMonthRemaining");
const months = Math.floor(daysRemaining / 30);
return `${months} months remaining`;
return t("usageIndicator.monthsRemaining", { count: months });
};
export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) {
const { t } = useTranslation();
const disableUsageIndicator = useDisableUsageIndicator();
const [isExpanded, setIsExpanded] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
@ -81,7 +84,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
setLicenseInfo(licenseResult);
} catch (err) {
console.error("Failed to fetch usage data:", err);
setError("Failed to load usage data");
setError(t("usageIndicator.failedToLoad"));
} finally {
setIsLoading(false);
}
@ -177,7 +180,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
hasError && "text-red-400 hover:text-red-600",
hasWarning && "text-yellow-500 hover:text-yellow-700",
)}
title="Show usage details"
title={t("usageIndicator.showUsageDetails")}
>
<Users className="h-3 w-3 flex-shrink-0" />
{hasAnyIssue && <span className="flex-shrink-0">{getStatusIcon()}</span>}
@ -200,12 +203,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
isLicenseExpiringSoon && "text-yellow-500",
)}
>
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
{daysUntilExpiration < 0 ? t("usageIndicator.expiredShort") : `${daysUntilExpiration}d`}
</span>
)}
{!data ||
(data.total_users === null && data.total_teams === null && !licenseInfo && (
<span className="truncate">Usage</span>
<span className="truncate">{t("usageIndicator.usageLabel")}</span>
))}
</div>
</button>
@ -223,7 +226,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
return (
<div className="flex items-center gap-3 px-3 py-2 text-gray-500" style={{ maxWidth: `${width}px` }}>
<Loader2 className="h-4 w-4 animate-spin flex-shrink-0" />
<span className="text-sm truncate">Loading...</span>
<span className="text-sm truncate">{t("common.loading")}</span>
</div>
);
}
@ -236,12 +239,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Users className="h-4 w-4 flex-shrink-0" />
<span className="text-sm truncate">{error || "No data"}</span>
<span className="text-sm truncate">{error || t("common.noData")}</span>
</div>
<button
onClick={() => setIsMinimized(true)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:bg-gray-100 rounded transition-all flex-shrink-0"
title="Minimize"
title={t("usageIndicator.minimize")}
>
<Minus className="h-3 w-3" />
</button>
@ -262,7 +265,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
)}
>
<Users className="h-4 w-4 flex-shrink-0" />
<span className="text-sm font-medium truncate">Usage Status</span>
<span className="text-sm font-medium truncate">{t("usageIndicator.usageStatus")}</span>
{hasAnyIssue && (
<Badge color={getStatusColor()} className="text-xs px-1.5 py-0.5 flex-shrink-0">
{getStatusIcon()}
@ -279,7 +282,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<button
onClick={() => setIsMinimized(true)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:bg-gray-100 rounded transition-all ml-1 flex-shrink-0"
title="Minimize"
title={t("usageIndicator.minimize")}
>
<Minus className="h-3 w-3 text-gray-400" />
</button>
@ -293,7 +296,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<div>
<div className="mb-1 flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span className="font-medium">License</span>
<span className="font-medium">{t("usageIndicator.licenseLabel")}</span>
</div>
<div
className={cn(
@ -307,7 +310,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
) : isLicenseExpiringSoon ? (
<TrendingUp className="h-3 w-3" />
) : null}
<span className="truncate">{formatExpirationDisplay(daysUntilExpiration)}</span>
<span className="truncate">{formatExpirationDisplay(daysUntilExpiration, t)}</span>
</div>
</div>
)}
@ -320,7 +323,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<span className="font-medium">
{data.total_users_used}/{data.total_users}
</span>
<span className="text-gray-500">users</span>
<span className="text-gray-500">{t("usageIndicator.usersLabel")}</span>
</div>
{/* User progress bar */}
@ -349,7 +352,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
) : (
<TrendingUp className="h-3 w-3" />
)}
<span className="truncate">Users {userMetrics.isOverLimit ? "Over Limit" : "Near Limit"}</span>
<span className="truncate">
{userMetrics.isOverLimit
? t("usageIndicator.usersOverLimit")
: t("usageIndicator.usersNearLimit")}
</span>
</div>
)}
</div>
@ -363,7 +370,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<span className="font-medium">
{data.total_teams_used}/{data.total_teams}
</span>
<span className="text-gray-500">teams</span>
<span className="text-gray-500">{t("usageIndicator.teamsLabel")}</span>
</div>
{/* Team progress bar */}
@ -392,7 +399,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
) : (
<TrendingUp className="h-3 w-3" />
)}
<span className="truncate">Teams {teamMetrics.isOverLimit ? "Over Limit" : "Near Limit"}</span>
<span className="truncate">
{teamMetrics.isOverLimit
? t("usageIndicator.teamsOverLimit")
: t("usageIndicator.teamsNearLimit")}
</span>
</div>
)}
</div>
@ -412,7 +423,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
className={cn(
"bg-white border border-gray-200 rounded-lg shadow-sm p-3 hover:shadow-md transition-all w-full",
)}
title="Show usage details"
title={t("usageIndicator.showUsageDetails")}
>
<div className="flex items-center gap-2">
<Users className="h-4 w-4 flex-shrink-0" />
@ -451,12 +462,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-700 border-gray-200",
)}
>
{daysUntilExpiration < 0 ? "Exp!" : `${daysUntilExpiration}d`}
{daysUntilExpiration < 0 ? t("usageIndicator.expiredShort") : `${daysUntilExpiration}d`}
</span>
)}
{!data ||
(data.total_users === null && data.total_teams === null && !licenseInfo && (
<span className="truncate">Usage</span>
<span className="truncate">{t("usageIndicator.usageLabel")}</span>
))}
</div>
</div>
@ -469,7 +480,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 w-full">
<div className="flex items-center justify-center gap-2 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-gray-500 truncate">Loading...</span>
<span className="text-sm text-gray-500 truncate">{t("common.loading")}</span>
</div>
</div>
);
@ -480,12 +491,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<div className="bg-white border border-gray-200 rounded-lg shadow-sm p-4 group w-full">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-500 truncate block">{error || "No data"}</span>
<span className="text-sm text-gray-500 truncate block">{error || t("common.noData")}</span>
</div>
<button
onClick={() => setIsMinimized(true)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded transition-all flex-shrink-0"
title="Minimize"
title={t("usageIndicator.minimize")}
>
<Minus className="h-3 w-3 text-gray-400" />
</button>
@ -499,12 +510,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 min-w-0 flex-1">
<Users className="h-4 w-4 flex-shrink-0" />
<span className="font-medium text-sm truncate">Usage</span>
<span className="font-medium text-sm truncate">{t("usageIndicator.usageLabel")}</span>
</div>
<button
onClick={() => setIsMinimized(true)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded transition-all flex-shrink-0"
title="Minimize"
title={t("usageIndicator.minimize")}
>
<Minus className="h-3 w-3 text-gray-400" />
</button>
@ -523,7 +534,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
<Calendar className="h-3 w-3" />
<span className="font-medium">License</span>
<span className="font-medium">{t("usageIndicator.licenseLabel")}</span>
<span
className={cn(
"ml-1 px-1.5 py-0.5 rounded border",
@ -532,11 +543,15 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
!isLicenseExpired && !isLicenseExpiringSoon && "bg-gray-50 text-gray-600 border-gray-200",
)}
>
{isLicenseExpired ? "Expired" : isLicenseExpiringSoon ? "Expiring soon" : "OK"}
{isLicenseExpired
? t("usageIndicator.expired")
: isLicenseExpiringSoon
? t("usageIndicator.expiringSoon")
: t("common.ok")}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Status:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.statusLabel")}</span>
<span
className={cn(
"font-medium text-right",
@ -544,12 +559,12 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
isLicenseExpiringSoon && "text-yellow-600",
)}
>
{formatExpirationDisplay(daysUntilExpiration)}
{formatExpirationDisplay(daysUntilExpiration, t)}
</span>
</div>
{licenseInfo.license_type && (
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Type:</span>
<span className="text-gray-600 text-xs">{t("common.type")}:</span>
<span className="font-medium text-right capitalize">{licenseInfo.license_type}</span>
</div>
)}
@ -567,7 +582,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
<Users className="h-3 w-3" />
<span className="font-medium">Users</span>
<span className="font-medium">{t("usageIndicator.usersSection")}</span>
<span
className={cn(
"ml-1 px-1.5 py-0.5 rounded border",
@ -576,17 +591,21 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
!userMetrics.isOverLimit && !userMetrics.isNearLimit && "bg-gray-50 text-gray-600 border-gray-200",
)}
>
{userMetrics.isOverLimit ? "Over limit" : userMetrics.isNearLimit ? "Near limit" : "OK"}
{userMetrics.isOverLimit
? t("usageIndicator.overLimit")
: userMetrics.isNearLimit
? t("usageIndicator.nearLimit")
: t("common.ok")}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Used:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.usedLabel")}</span>
<span className="font-medium text-right">
{data.total_users_used}/{data.total_users}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Remaining:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.remainingLabel")}</span>
<span
className={cn(
"font-medium text-right",
@ -598,7 +617,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Usage:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.usageRowLabel")}</span>
<span className="font-medium text-right">{Math.round(userMetrics.usagePercentage)}%</span>
</div>
@ -628,7 +647,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
>
<div className="flex items-center gap-2 text-xs text-gray-600 mb-1">
<UserCheck className="h-3 w-3" />
<span className="font-medium">Teams</span>
<span className="font-medium">{t("usageIndicator.teamsSection")}</span>
<span
className={cn(
"ml-1 px-1.5 py-0.5 rounded border",
@ -637,17 +656,21 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
!teamMetrics.isOverLimit && !teamMetrics.isNearLimit && "bg-gray-50 text-gray-600 border-gray-200",
)}
>
{teamMetrics.isOverLimit ? "Over limit" : teamMetrics.isNearLimit ? "Near limit" : "OK"}
{teamMetrics.isOverLimit
? t("usageIndicator.overLimit")
: teamMetrics.isNearLimit
? t("usageIndicator.nearLimit")
: t("common.ok")}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Used:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.usedLabel")}</span>
<span className="font-medium text-right">
{data.total_teams_used}/{data.total_teams}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Remaining:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.remainingLabel")}</span>
<span
className={cn(
"font-medium text-right",
@ -659,7 +682,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600 text-xs">Usage:</span>
<span className="text-gray-600 text-xs">{t("usageIndicator.usageRowLabel")}</span>
<span className="font-medium text-right">{Math.round(teamMetrics.usagePercentage)}%</span>
</div>

View file

@ -838,6 +838,90 @@
"addOverrideFailed": "Failed to add override: {{error}}",
"removeOverrideFailed": "Failed to remove override: {{error}}"
},
"toolPolicies": {
"title": "Tool Policies",
"newToday": "New Today",
"totalToolsDiscovered": "Total Tools Discovered",
"blockedTools": "Blocked Tools",
"activeTeams": "Active Teams",
"needsReview": "Needs Review",
"needsReviewDesc_one": "{{count}} new tool discovered that requires a policy decision.",
"needsReviewDesc_other": "{{count}} new tools discovered that require policy decisions.",
"review": "Review",
"searchByToolName": "Search by Tool Name",
"liveTail": "Live Tail",
"fetch": "Fetch",
"fetching": "Fetching",
"showing": "Showing {{from}} - {{to}} of {{total}} results",
"page": "Page {{current}} of {{total}}",
"filtersButton": "Filters",
"autoRefreshing": "Auto-refreshing every 15 seconds",
"stop": "Stop",
"colDiscovered": "Discovered",
"colToolName": "Tool Name",
"inputPolicy": "Input Policy",
"outputPolicy": "Output Policy",
"colCalls": "# Calls",
"teamName": "Team Name",
"colKeyHash": "Key Hash",
"keyName": "Key Name",
"colUserAgent": "User Agent",
"loadingTools": "Loading tools…",
"noToolsDiscovered": "No tools discovered yet. Make a chat completion that returns tool_calls to start auto-discovery.",
"clickToViewDetails": "Click to view details and block for team/key",
"trendPositive": "+{{diff}} since yesterday",
"trendNegative": "{{diff}} since yesterday",
"updateInputPolicyFailed": "Failed to update input policy: {{error}}",
"updateOutputPolicyFailed": "Failed to update output policy: {{error}}",
"failedToLoadTools": "Failed to load tools"
},
"uiAccessControlForm": {
"description": "Configure who can access the UI interface and how group information is extracted from JWT tokens.",
"uiAccessModeLabel": "UI Access Mode",
"uiAccessModeTooltip": "Controls who can access the UI interface",
"selectAccessModePlaceholder": "Select access mode",
"optionAllAuthenticatedUsers": "All Authenticated Users",
"optionRestrictedSsoGroup": "Restricted SSO Group",
"restrictedSsoGroupLabel": "Restricted SSO Group",
"restrictedSsoGroupRequired": "Please enter the restricted SSO group",
"ssoGroupJwtFieldLabel": "SSO Group JWT Field",
"ssoGroupJwtFieldTooltip": "JWT field name that contains team/group information. Use dot notation to access nested fields.",
"updateButton": "Update UI Access Control",
"noAccessToken": "No access token available",
"saveSettingsFailed": "Failed to save UI access settings"
},
"usageIndicator": {
"showUsageDetails": "Show usage details",
"minimize": "Minimize",
"usageLabel": "Usage",
"usageStatus": "Usage Status",
"failedToLoad": "Failed to load usage data",
"expiredShort": "Exp!",
"licenseLabel": "License",
"usersLabel": "users",
"teamsLabel": "teams",
"usersSection": "Users",
"teamsSection": "Teams",
"usersOverLimit": "Users Over Limit",
"usersNearLimit": "Users Near Limit",
"teamsOverLimit": "Teams Over Limit",
"teamsNearLimit": "Teams Near Limit",
"overLimit": "Over limit",
"nearLimit": "Near limit",
"statusLabel": "Status:",
"usedLabel": "Used:",
"remainingLabel": "Remaining:",
"usageRowLabel": "Usage:",
"noExpiration": "No expiration",
"expired": "Expired",
"expiresToday": "Expires today",
"daysRemaining_one": "{{count}} day remaining",
"daysRemaining_other": "{{count}} days remaining",
"oneMonthRemaining": "1 month remaining",
"monthsRemaining_one": "{{count}} month remaining",
"monthsRemaining_other": "{{count}} months remaining",
"expiringSoon": "Expiring soon"
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -838,6 +838,90 @@
"addOverrideFailed": "添加覆盖规则失败:{{error}}",
"removeOverrideFailed": "移除覆盖规则失败:{{error}}"
},
"toolPolicies": {
"title": "工具策略",
"newToday": "今日新增",
"totalToolsDiscovered": "已发现工具总数",
"blockedTools": "已屏蔽工具",
"activeTeams": "活跃团队",
"needsReview": "待审核",
"needsReviewDesc_one": "发现 {{count}} 个新工具,需要制定策略。",
"needsReviewDesc_other": "发现 {{count}} 个新工具,需要制定策略。",
"review": "审核",
"searchByToolName": "按工具名称搜索",
"liveTail": "实时追踪",
"fetch": "获取",
"fetching": "获取中",
"showing": "显示第 {{from}} - {{to}} 条,共 {{total}} 条",
"page": "第 {{current}} 页,共 {{total}} 页",
"filtersButton": "筛选",
"autoRefreshing": "每 15 秒自动刷新",
"stop": "停止",
"colDiscovered": "发现时间",
"colToolName": "工具名称",
"inputPolicy": "输入策略",
"outputPolicy": "输出策略",
"colCalls": "调用次数",
"teamName": "团队名称",
"colKeyHash": "密钥哈希",
"keyName": "密钥名称",
"colUserAgent": "User Agent",
"loadingTools": "加载工具中…",
"noToolsDiscovered": "尚未发现工具。请发起一次返回 tool_calls 的对话补全以启动自动发现。",
"clickToViewDetails": "点击查看详情并为团队/密钥屏蔽",
"trendPositive": "较昨日 +{{diff}}",
"trendNegative": "较昨日 {{diff}}",
"updateInputPolicyFailed": "更新输入策略失败:{{error}}",
"updateOutputPolicyFailed": "更新输出策略失败:{{error}}",
"failedToLoadTools": "加载工具失败"
},
"uiAccessControlForm": {
"description": "配置谁可以访问 UI 界面,以及如何从 JWT Token 中提取用户组信息。",
"uiAccessModeLabel": "UI 访问模式",
"uiAccessModeTooltip": "控制谁可以访问 UI 界面",
"selectAccessModePlaceholder": "选择访问模式",
"optionAllAuthenticatedUsers": "所有已认证用户",
"optionRestrictedSsoGroup": "受限 SSO 用户组",
"restrictedSsoGroupLabel": "受限 SSO 用户组",
"restrictedSsoGroupRequired": "请输入受限 SSO 用户组",
"ssoGroupJwtFieldLabel": "SSO 用户组 JWT 字段",
"ssoGroupJwtFieldTooltip": "包含团队/用户组信息的 JWT 字段名称。使用点号表示法访问嵌套字段。",
"updateButton": "更新界面访问控制",
"noAccessToken": "无可用访问令牌",
"saveSettingsFailed": "保存界面访问设置失败"
},
"usageIndicator": {
"showUsageDetails": "显示用量详情",
"minimize": "最小化",
"usageLabel": "用量",
"usageStatus": "用量状态",
"failedToLoad": "加载用量数据失败",
"expiredShort": "已过期!",
"licenseLabel": "许可证",
"usersLabel": "用户",
"teamsLabel": "团队",
"usersSection": "用户",
"teamsSection": "团队",
"usersOverLimit": "用户超出限制",
"usersNearLimit": "用户接近限制",
"teamsOverLimit": "团队超出限制",
"teamsNearLimit": "团队接近限制",
"overLimit": "超出限制",
"nearLimit": "接近限制",
"statusLabel": "状态:",
"usedLabel": "已用:",
"remainingLabel": "剩余:",
"usageRowLabel": "用量:",
"noExpiration": "无过期时间",
"expired": "已过期",
"expiresToday": "今日过期",
"daysRemaining_one": "剩余 {{count}} 天",
"daysRemaining_other": "剩余 {{count}} 天",
"oneMonthRemaining": "剩余 1 个月",
"monthsRemaining_one": "剩余 {{count}} 个月",
"monthsRemaining_other": "剩余 {{count}} 个月",
"expiringSoon": "即将过期"
},
"common": {
"save": "保存",
"cancel": "取消",