refactor(ui): move remaining shell and nav text onto i18n (#50148)

Moves the leftover English in the sidebar, page descriptions, account
menu, usage card and a handful of shared widgets into en.json. Option
lists and label maps now hold message keys and are translated where they
render, while every option value, id and stored setting stays the same
string. Lookups keyed by a server value check Object.hasOwn first
This commit is contained in:
yuneng-jiang 2026-11-05 12:42:37 -08:00 • committed by GitHub
parent 625353fabe
commit 4cd169f3bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 763 additions and 255 deletions

View file

@ -3,7 +3,7 @@
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils";
import { useEffect, useMemo } from "react";
import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole } from "@/utils/roles";
import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole, userRoleDisplayLabel } from "@/utils/roles";
import { useCurrentUser } from "./currentUser/useCurrentUser";
import { redirectToLogin } from "./redirectToLogin";
import { useUIConfig } from "./uiConfig/useUIConfig";
@ -44,7 +44,7 @@ const useAuthorized = () => {
userId: currentUser?.user_id ?? null,
userEmail: currentUser?.user_email ?? null,
userRole: effectiveSessionRole(serverRole),
userRoleLabel: formatUserRole(serverRole),
userRoleLabel: userRoleDisplayLabel(formatUserRole(serverRole)),
isViewOnly: isViewOnlySessionRole(serverRole),
premiumUser: decoded?.premium_user ?? null,
disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null,

View file

@ -758,7 +758,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
>
{(control) => (
<Select
items={TRANSPORT_ITEMS}
items={TRANSPORT_ITEMS.map((item) => ({ ...item, label: t(item.label) }))}
value={(control.value as string | undefined) ?? null}
onValueChange={handleTransportSelected(control.onChange)}
>
@ -879,14 +879,17 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}}
>
{(control) => (
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
<Select
{...selectControl<string>(control)}
items={AUTH_TYPE_ITEMS.map((item) => ({ ...item, label: t(item.label) }))}
>
<SelectTrigger {...selectTriggerControl(control)} className="w-full rounded-lg">
<SelectValue placeholder={t("mcpServers.createMcpServer.auth.typePlaceholder")} />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.label)}
</SelectItem>
))}
</SelectContent>

View file

@ -379,7 +379,7 @@ const HealthChip: FC<HealthChipProps> = ({
/>
<TooltipContent side="top" className="max-w-xs">
<div className="mb-1 font-semibold">{t("mcpServers.serverCard.healthChip.health", { status })}</div>
{status === "reachable" && <div className="mb-1 text-xs">{MCP_REACHABLE_DESCRIPTION}</div>}
{status === "reachable" && <div className="mb-1 text-xs">{t(MCP_REACHABLE_DESCRIPTION)}</div>}
{lastCheck && (
<div className="mb-1 text-xs">
{t("mcpServers.serverCard.healthChip.lastCheck", { time: new Date(lastCheck).toLocaleString() })}

View file

@ -24,7 +24,7 @@ export const TransportSelectItems: FC<{ stdioEnabled: boolean }> = ({ stdioEnabl
const disabled = item.value === TRANSPORT.STDIO && !stdioEnabled;
return (
<SelectItem key={item.value} value={item.value} disabled={disabled}>
{item.label}
{t(item.label)}
{disabled && <SimpleTooltip content={t(STDIO_DISABLED_MESSAGE_KEY)} className="pointer-events-auto" />}
</SelectItem>
);

View file

@ -896,7 +896,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
>
{(control) => (
<Select
items={TRANSPORT_ITEMS}
items={TRANSPORT_ITEMS.map((item) => ({ ...item, label: t(item.label) }))}
value={(control.value as string | undefined) ?? null}
onValueChange={handleTransportSelected(control.onChange)}
>
@ -1014,14 +1014,17 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}}
>
{(control) => (
<Select {...selectControl<string>(control)} items={AUTH_TYPE_ITEMS}>
<Select
{...selectControl<string>(control)}
items={AUTH_TYPE_ITEMS.map((item) => ({ ...item, label: t(item.label) }))}
>
<SelectTrigger {...selectTriggerControl(control)} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AUTH_TYPE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.label)}
</SelectItem>
))}
</SelectContent>

View file

@ -158,7 +158,7 @@ export const getMCPHubTableColumns = ({ onServerClick }: MCPHubTableColumnsDeps)
<StatusBadge
tone={STATUS_TONES[row.original.status] || "neutral"}
label={row.original.status || i18n.t("aiHub.mcpHubColumns.statusUnknown")}
tooltip={row.original.status === "reachable" ? MCP_REACHABLE_DESCRIPTION : undefined}
tooltip={row.original.status === "reachable" ? i18n.t(MCP_REACHABLE_DESCRIPTION) : undefined}
/>
),
},

View file

@ -140,7 +140,8 @@ function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }
const pagesGroups = pages.length > 0 ? [{ heading: t("shell.commandPalette.groups.pages"), entries: pages }] : [];
if (scope === "global") {
const showSearchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase());
const showSearchKeysAction =
!trimmedQuery || t("shell.commandPalette.searchKeys.title").toLowerCase().includes(trimmedQuery.toLowerCase());
const searchKeysAction: PaletteEntry = {
id: "action:search-keys",
kind: "search-keys",

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import type { ReactNode } from "react";
import { Trans } from "react-i18next";
import i18n from "@/i18n/i18n";
@ -12,9 +13,9 @@ export interface DeploymentWarningContent {
docsUrl?: string;
}
const STATIC_CONTENT: Readonly<Record<string, DeploymentWarningContent>> = {
const STATIC_CONTENT: Readonly<Record<string, DeploymentWarningContent & { title: ParseKeys }>> = {
env_credential_login: {
title: "Environment-credential login is enabled",
title: "shell.deploymentWarnings.envCredentialLogin.title",
fix: (
<Trans
i18nKey="shell.deploymentWarnings.envCredentialLogin.fix"
@ -28,12 +29,12 @@ const STATIC_CONTENT: Readonly<Record<string, DeploymentWarningContent>> = {
docsUrl: "https://docs.litellm.ai/docs/proxy/ui",
},
no_redis: {
title: "No Redis configured",
title: "shell.deploymentWarnings.noRedis.title",
fix: <Trans i18nKey="shell.deploymentWarnings.noRedis.fix" />,
docsUrl: "https://docs.litellm.ai/docs/proxy/redis_requirements",
},
detailed_debug: {
title: "Detailed debug logging is on",
title: "shell.deploymentWarnings.detailedDebug.title",
fix: (
<Trans
i18nKey="shell.deploymentWarnings.detailedDebug.fix"
@ -74,5 +75,9 @@ export const deploymentWarningContent = (warningId: string, now: Date = new Date
if (warningId.startsWith(LICENSE_EXPIRY_PREFIX)) {
return licenseExpiryContent(warningId.slice(LICENSE_EXPIRY_PREFIX.length), now);
}
return STATIC_CONTENT[warningId] ?? { title: warningId };
if (!Object.hasOwn(STATIC_CONTENT, warningId)) {
return { title: warningId };
}
const content = STATIC_CONTENT[warningId];
return { ...content, title: i18n.t(content.title) };
};

View file

@ -1,5 +1,6 @@
"use client";
import type { ParseKeys } from "i18next";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BookOpen, Github, Newspaper, ScrollText, Slack } from "lucide-react";
@ -39,11 +40,17 @@ const COMMUNITY = [
Icon: Github,
},
] as const;
const DOCS = [
{ href: "https://docs.litellm.ai/docs/", label: "Docs", Icon: BookOpen },
{ href: "https://docs.litellm.ai/release_notes", label: "Releases", Icon: ScrollText },
type ResourceLink = { href: string; label: ParseKeys; Icon: typeof BookOpen };
const DOCS: readonly ResourceLink[] = [
{ href: "https://docs.litellm.ai/docs/", label: "shell.dashboardResources.links.docs", Icon: BookOpen },
{ href: "https://docs.litellm.ai/release_notes", label: "shell.dashboardResources.links.releases", Icon: ScrollText },
];
const BLOG = { href: "https://docs.litellm.ai/blog", label: "Blog", Icon: Newspaper };
const BLOG: ResourceLink = {
href: "https://docs.litellm.ai/blog",
label: "shell.dashboardResources.links.blog",
Icon: Newspaper,
};
export function DashboardCommunityLinks({ className }: { className?: string }) {
const { t } = useTranslation();
@ -81,7 +88,10 @@ export function DashboardResources({ children }: { children?: ReactNode }) {
const hideBlog = useDisableBlogPosts();
const hideCommunity = useDisableShowPrompts();
const { data, isPending, isError, refetch } = useBlogPosts();
const links = [...DOCS, ...(hideBlog ? [] : [BLOG]), ...(hideCommunity ? [] : COMMUNITY)];
const links = [
...[...DOCS, ...(hideBlog ? [] : [BLOG])].map((link) => ({ ...link, label: t(link.label) })),
...(hideCommunity ? [] : COMMUNITY),
];
return (
<>
{!hideBlog && (

View file

@ -37,13 +37,13 @@ interface Announcement {
href: string;
}
const AUTO_ROUTER_ANNOUNCEMENT: Announcement = {
const autoRouterAnnouncement = (): Announcement => ({
readKey: "litellmHideAutoRouterAnnouncement",
title: "LiteLLM Auto Router",
description: "Route every request to the cheapest model that can handle it, no prompt changes needed.",
linkLabel: "Read the docs",
title: i18n.t("shell.notifications.autoRouter.title"),
description: i18n.t("shell.notifications.autoRouter.description"),
linkLabel: i18n.t("shell.notifications.autoRouter.link"),
href: AUTO_ROUTER_DOCS_URL,
};
});
const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
[
@ -121,7 +121,7 @@ export const NotificationsBellView: React.FC<NotificationsBellViewProps> = ({
}) => {
const { t } = useTranslation();
const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
const announcements = upgrade ? [upgrade, autoRouterAnnouncement()] : [autoRouterAnnouncement()];
const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
const openAlerts = (deprecationAlerts ?? []).filter((alert) => effectiveAlertStatus(alert) === AlertStatus.Open);

View file

@ -332,7 +332,7 @@ export default function UISettings() {
<Select
value={usageRange}
disabled={isUpdating || usageRangeConfigOwned}
items={relativeTimeOptions.map((option) => ({ value: option.settingValue, label: option.label }))}
items={relativeTimeOptions.map((option) => ({ value: option.settingValue, label: t(option.label) }))}
onValueChange={(value) => {
if (value === null) return;
updateSettings(
@ -354,7 +354,7 @@ export default function UISettings() {
<SelectContent>
{relativeTimeOptions.map((option) => (
<SelectItem key={option.settingValue} value={option.settingValue}>
{option.label}
{t(option.label)}
</SelectItem>
))}
</SelectContent>

View file

@ -21,6 +21,7 @@ import { isProxyAdminRole } from "@/utils/roles";
import { ChevronsUpDown, Crown, IdCard, KeyRound, LogOut, Mail, ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { Trans, useTranslation } from "react-i18next";
const RELEASE_NOTES_URL = "https://docs.litellm.ai/release_notes";
@ -96,6 +97,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
accessToken,
loginMethod,
} = useAuthorized();
const { t } = useTranslation();
const router = useRouter();
const [open, setOpen] = React.useState(false);
const { data: healthData } = useHealthReadinessDetails(accessToken);
@ -119,29 +121,29 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
const toggles = [
{
key: "disableShowNewBadge",
label: "Hide New Feature Indicators",
ariaLabel: "Toggle hide new feature indicators",
label: t("shell.sidebarAccountMenu.toggles.newBadge.label"),
ariaLabel: t("shell.sidebarAccountMenu.toggles.newBadge.ariaLabel"),
checked: disableShowNewBadge,
onCheckedChange: (checked: boolean) => setFlag("disableShowNewBadge", checked),
},
{
key: "disableShowPrompts",
label: "Hide All Prompts",
ariaLabel: "Toggle hide all prompts",
label: t("shell.sidebarAccountMenu.toggles.prompts.label"),
ariaLabel: t("shell.sidebarAccountMenu.toggles.prompts.ariaLabel"),
checked: disableShowPrompts,
onCheckedChange: (checked: boolean) => setFlag("disableShowPrompts", checked),
},
{
key: "disableBlogPosts",
label: "Hide Blog Posts",
ariaLabel: "Toggle hide blog posts",
label: t("shell.sidebarAccountMenu.toggles.blogPosts.label"),
ariaLabel: t("shell.sidebarAccountMenu.toggles.blogPosts.ariaLabel"),
checked: disableBlogPosts,
onCheckedChange: (checked: boolean) => setFlag("disableBlogPosts", checked),
},
{
key: "disableBouncingIcon",
label: "Hide Bouncing Icon",
ariaLabel: "Toggle hide bouncing icon",
label: t("shell.sidebarAccountMenu.toggles.bouncingIcon.label"),
ariaLabel: t("shell.sidebarAccountMenu.toggles.bouncingIcon.ariaLabel"),
checked: disableBouncingIcon,
onCheckedChange: (checked: boolean) => setFlag("disableBouncingIcon", checked),
},
@ -151,7 +153,10 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
const initials = initialsFromIdentity(userEmail, userId);
const hue = hueFromString(seed);
const displayName = navAccountDisplayName(userEmail, userId);
const triggerLabel = `Account menu — ${userRole ?? "Unknown role"} — signed in as ${userEmail || userId || "unknown"}`;
const triggerLabel = t("shell.sidebarAccountMenu.triggerLabel", {
role: userRole ?? t("shell.sidebarAccountMenu.unknownRole"),
identity: userEmail || userId || t("shell.sidebarAccountMenu.unknownIdentity"),
});
return (
<Popover open={open} onOpenChange={setOpen}>
@ -192,7 +197,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
<span
className="animate-bounce text-lg leading-none"
style={{ animationDuration: "2s" }}
title="Thanks for using LiteLLM!"
title={t("shell.sidebarAccountMenu.thanks")}
aria-hidden
>
🌴
@ -211,27 +216,27 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
</div>
<div className="flex flex-col px-3 py-2">
<InfoRow icon={<Crown className="size-[17px]" />} label="Tier">
<InfoRow icon={<Crown className="size-[17px]" />} label={t("shell.sidebarAccountMenu.tier")}>
{premiumUser ? (
<Badge variant="outline" className="gap-1 border-warning/30 bg-warning/10 text-warning">
<Crown />
Premium
{t("shell.sidebarAccountMenu.premium")}
</Badge>
) : (
<Badge variant="secondary" className="gap-1" title="Upgrade to Premium for advanced features">
<Badge variant="secondary" className="gap-1" title={t("shell.sidebarAccountMenu.upgradeHint")}>
<Crown />
Standard
{t("shell.sidebarAccountMenu.standard")}
</Badge>
)}
</InfoRow>
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label="Role">
<InfoRow icon={<ShieldCheck className="size-[17px]" />} label={t("shell.sidebarAccountMenu.role")}>
<Badge variant="secondary">{userRole}</Badge>
</InfoRow>
<InfoRow icon={<Mail className="size-[17px]" />} label="Email">
<MonoValue value={userEmail} copyLabel="Copy email" />
<InfoRow icon={<Mail className="size-[17px]" />} label={t("shell.sidebarAccountMenu.email")}>
<MonoValue value={userEmail} copyLabel={t("shell.sidebarAccountMenu.copyEmail")} />
</InfoRow>
<InfoRow icon={<IdCard className="size-[17px]" />} label="User ID">
<MonoValue value={userId} copyLabel="Copy user ID" />
<InfoRow icon={<IdCard className="size-[17px]" />} label={t("shell.sidebarAccountMenu.userId")}>
<MonoValue value={userId} copyLabel={t("shell.sidebarAccountMenu.copyUserId")} />
</InfoRow>
</div>
@ -251,12 +256,14 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
))}
{premiumUser === true && canUseLiteAdmin && (
<div className="flex h-[38px] items-center justify-between gap-3 px-3">
<span className="text-[13px] text-foreground">Hide {ARIA_AGENT_LABEL}</span>
<span className="text-[13px] text-foreground">
<Trans i18nKey="shell.sidebarAccountMenu.hideAria" components={{ name: <>{ARIA_AGENT_LABEL}</> }} />
</span>
<Switch
size="sm"
checked={disableLiteAdmin}
onCheckedChange={setDisableLiteAdmin}
aria-label={`Toggle hide ${ARIA_AGENT_LABEL}`}
aria-label={t("shell.sidebarAccountMenu.toggleHideAria", { name: ARIA_AGENT_LABEL })}
/>
</div>
)}
@ -274,7 +281,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
>
<KeyRound className="size-[19px] text-muted-foreground" />
Change Password
{t("shell.sidebarAccountMenu.changePassword")}
</Button>
)}
@ -284,7 +291,7 @@ const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ onLogout, colla
className="h-[42px] w-full justify-start gap-2.5 rounded-none px-3 text-sm font-medium text-foreground"
>
<LogOut className="size-[19px] text-muted-foreground" />
Logout
{t("shell.sidebarAccountMenu.logout")}
</Button>
</PopoverContent>
</Popover>

View file

@ -4,7 +4,9 @@ import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/shared/Meter";
import { useQuery } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { Award, ChevronDown, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { getRemainingUsers } from "./networking";
interface SidebarUsageCardProps {
@ -26,9 +28,17 @@ const meterTone = (pct: number): "default" | "warning" | "over" => {
};
const UsageMeter = ({ label, used, total }: MeterData) => {
const { t } = useTranslation();
const pct = total > 0 ? (used / total) * 100 : 0;
return (
<Meter value={used} max={total} aria-valuetext={`${used.toLocaleString()} of ${total.toLocaleString()}`}>
<Meter
value={used}
max={total}
aria-valuetext={t("shell.sidebarUsageCard.meterValue", {
used: used.toLocaleString(),
total: total.toLocaleString(),
})}
>
<div className="flex items-baseline justify-between gap-2">
<MeterLabel>{label}</MeterLabel>
<span className="text-xs font-medium tabular-nums">
@ -53,11 +63,15 @@ const remainingUsersQuery = (accessToken: string | null) => ({
staleTime: 5 * 60 * 1000,
});
const buildMeters = (data: RemainingUsage | null): MeterData[] => {
const buildMeters = (data: RemainingUsage | null, t: TFunction): MeterData[] => {
if (!data) return [];
return [
...(data.total_users != null ? [{ label: "Seats", used: data.total_users_used, total: data.total_users }] : []),
...(data.total_teams != null ? [{ label: "Teams", used: data.total_teams_used, total: data.total_teams }] : []),
...(data.total_users != null
? [{ label: t("shell.sidebarUsageCard.seats"), used: data.total_users_used, total: data.total_users }]
: []),
...(data.total_teams != null
? [{ label: t("shell.sidebarUsageCard.teams"), used: data.total_teams_used, total: data.total_teams }]
: []),
];
};
@ -68,6 +82,7 @@ const buildMeters = (data: RemainingUsage | null): MeterData[] => {
* design's Spend / API-request meters are intentionally omitted.
*/
export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail }: SidebarUsageCardProps) {
const { t } = useTranslation();
const licenseInfo = useLicenseInfo(accessToken).data ?? null;
const { data: usageData, isLoading } = useQuery(remainingUsersQuery(accessToken));
const data = usageData ?? null;
@ -84,7 +99,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
<Button
variant="outline"
onClick={onExpandRail}
title="Enterprise usage"
title={t("shell.sidebarUsageCard.title")}
className="h-9 w-full rounded-lg border-sidebar-border bg-sidebar text-sidebar-primary shadow-none hover:bg-sidebar-accent hover:text-sidebar-primary/80"
>
<Award className="size-[18px]" strokeWidth={1.75} />
@ -92,8 +107,10 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
);
}
const subtitle = licenseInfo?.expiration_date ? formatExpirationStatus(licenseInfo.expiration_date) : "Active plan";
const meters = buildMeters(data);
const subtitle = licenseInfo?.expiration_date
? formatExpirationStatus(licenseInfo.expiration_date)
: t("shell.sidebarUsageCard.activePlan");
const meters = buildMeters(data, t);
return (
<Collapsible defaultOpen className="overflow-hidden rounded-xl border border-sidebar-border bg-sidebar">
@ -102,7 +119,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
<Award className="size-4" strokeWidth={1.75} />
</span>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13px] font-semibold text-foreground">Enterprise usage</span>
<span className="block text-[13px] font-semibold text-foreground">{t("shell.sidebarUsageCard.title")}</span>
<span className="block truncate text-[11px] text-muted-foreground">{subtitle}</span>
</span>
<ChevronDown className="size-4 flex-none -rotate-90 text-muted-foreground transition-transform group-data-[panel-open]/usage:rotate-0" />
@ -111,7 +128,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail
<CollapsibleContent className="flex flex-col gap-3 px-3 pt-0.5 pb-3">
{isLoading && meters.length === 0 ? (
<div className="flex items-center gap-2 py-1 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> Loading…
<Loader2 className="size-3.5 animate-spin" /> {t("shell.sidebarUsageCard.loading")}
</div>
) : (
meters.map((m) => <UsageMeter key={m.label} {...m} />)

View file

@ -1,3 +1,5 @@
import i18n from "@/i18n/i18n";
export interface KeyModelScope {
hasModelAccess: boolean;
label: string | null;
@ -7,8 +9,6 @@ const MANAGEMENT_ROUTES_PRESET = "management_routes";
const INFO_ROUTES_PRESET = "info_routes";
const SCIM_ROUTE_PREFIX = "/scim";
const MANAGEMENT_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Management" };
const READ_ONLY_SCOPE: KeyModelScope = { hasModelAccess: false, label: "Read-only" };
const SCIM_SCOPE: KeyModelScope = { hasModelAccess: false, label: "SCIM" };
const FULL_MODEL_ACCESS: KeyModelScope = { hasModelAccess: true, label: null };
@ -22,11 +22,11 @@ export const deriveKeyModelScope = (
keyType?: string | null,
): KeyModelScope => {
if (keyType === "management") {
return MANAGEMENT_SCOPE;
return { hasModelAccess: false, label: i18n.t("keyScope.management") };
}
if (keyType === "read_only") {
return READ_ONLY_SCOPE;
return { hasModelAccess: false, label: i18n.t("keyScope.readOnly") };
}
if (!Array.isArray(allowedRoutes) || allowedRoutes.length === 0) {
@ -38,11 +38,11 @@ export const deriveKeyModelScope = (
}
if (isOnlyPreset(allowedRoutes, MANAGEMENT_ROUTES_PRESET)) {
return MANAGEMENT_SCOPE;
return { hasModelAccess: false, label: i18n.t("keyScope.management") };
}
if (isOnlyPreset(allowedRoutes, INFO_ROUTES_PRESET)) {
return READ_ONLY_SCOPE;
return { hasModelAccess: false, label: i18n.t("keyScope.readOnly") };
}
return FULL_MODEL_ACCESS;

View file

@ -64,6 +64,9 @@ import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
import { AriaSidebarLink } from "@/components/liteadmin/LiteAdmin";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import type { ParseKeys } from "i18next";
import i18n from "@/i18n/i18n";
import { cn } from "@/lib/cva.config";
import { rolesWithCapability } from "../utils/capabilities";
import {
@ -198,13 +201,13 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "AI GATEWAY",
items: [
{ key: "home", page: "home", label: "Home", icon: <House {...ICON} /> },
{ key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: <KeyRound {...ICON} /> },
{ key: "home", page: "home", label: "shell.nav.items.home", icon: <House {...ICON} /> },
{ key: "api-keys", page: "api-keys", label: "shell.nav.items.virtualKeys", icon: <KeyRound {...ICON} /> },
{
key: "llm-playground",
page: "llm-playground",
route: "playground",
label: "Playground",
label: "shell.nav.items.playground",
icon: <PlayCircle {...ICON} />,
roles: rolesWithWriteAccess,
},
@ -212,67 +215,77 @@ const menuGroups: MenuGroup[] = [
key: "models",
page: "models",
route: "models-and-endpoints",
label: "Models + Endpoints",
label: "shell.nav.items.modelsAndEndpoints",
icon: <Network {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{
key: "agentic",
page: "agentic",
label: "Agentic",
label: "shell.nav.items.agentic",
icon: <Bot {...ICON} />,
children: [
{
key: "agents",
page: "agents",
label: "Agents",
label: "shell.nav.items.agents",
icon: <Bot {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{
key: "workflows",
page: "workflows",
label: "Workflow Runs",
label: "shell.nav.items.workflowRuns",
icon: <Workflow {...ICON} />,
roles: rolesWithCapability("viewWorkflowRuns"),
},
{
key: "memory",
page: "memory",
label: "Memory",
label: "shell.nav.items.memory",
icon: <Database {...ICON} />,
roles: rolesWithCapability("viewMemory"),
},
],
},
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
{ key: "mcp-servers", page: "mcp-servers", label: "shell.nav.items.mcpServers", icon: <Server {...ICON} /> },
{
key: "skills",
page: "skills",
label: "Skills",
label: "shell.nav.items.skills",
icon: <Blocks {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles, formatUserRole("org_admin")],
},
{ key: "guardrails", page: "guardrails", label: "Guardrails", icon: <Shield {...ICON} /> },
{ key: "guardrails", page: "guardrails", label: "shell.nav.items.guardrails", icon: <Shield {...ICON} /> },
{
key: "policies",
page: "policies",
label: "Policies",
label: "shell.nav.items.policies",
icon: <ScrollText {...ICON} />,
roles: rolesWithCapability("viewPolicies"),
},
{
key: "tools",
page: "tools",
label: "Tools",
label: "shell.nav.items.tools",
icon: <Wrench {...ICON} />,
children: [
{ key: "search-tools", page: "search-tools", label: "Search Tools", icon: <Search {...ICON} /> },
{ key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: <Database {...ICON} /> },
{
key: "search-tools",
page: "search-tools",
label: "shell.nav.items.searchTools",
icon: <Search {...ICON} />,
},
{
key: "vector-stores",
page: "vector-stores",
label: "shell.nav.items.vectorStores",
icon: <Database {...ICON} />,
},
{
key: "tool-policies",
page: "tool-policies",
label: "Tool Policies",
label: "shell.nav.items.toolPolicies",
icon: <ShieldCheck {...ICON} />,
roles: rolesWithCapability("viewToolPolicies"),
},
@ -289,12 +302,12 @@ const menuGroups: MenuGroup[] = [
route: "usage",
icon: <BarChart3 {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
label: "Usage",
label: "shell.nav.items.usage",
},
{
key: "alerts",
page: "alerts",
label: "Alerts",
label: "shell.nav.items.alerts",
icon: <Bell {...ICON} />,
roles: rolesWithCapability("viewGlobalSpend"),
},
@ -305,7 +318,7 @@ const menuGroups: MenuGroup[] = [
roles: all_admin_roles,
label: (
<span className="flex items-center gap-2">
Model Leaderboard <BetaBadge />
<Trans i18nKey="shell.nav.items.modelLeaderboard" /> <BetaBadge />
</span>
),
},
@ -314,15 +327,15 @@ const menuGroups: MenuGroup[] = [
page: "cost-optimization",
icon: <PiggyBank {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
label: "Cost Optimization",
label: "shell.nav.items.costOptimization",
},
{ key: "logs", page: "logs", label: "Logs", icon: <Activity {...ICON} /> },
{ key: "logs", page: "logs", label: "shell.nav.items.logs", icon: <Activity {...ICON} /> },
{
key: "lens",
page: "lens",
label: (
<span className="flex items-center gap-2">
Lens <BetaBadge />
<Trans i18nKey="shell.nav.items.lens" /> <BetaBadge />
</span>
),
icon: <Aperture {...ICON} />,
@ -330,7 +343,7 @@ const menuGroups: MenuGroup[] = [
{
key: "guardrails-monitor",
page: "guardrails-monitor",
label: "Guardrails Monitor",
label: "shell.nav.items.guardrailsMonitor",
icon: <HeartPulse {...ICON} />,
roles: rolesWithCapability("viewGuardrailUsage"),
},
@ -339,79 +352,102 @@ const menuGroups: MenuGroup[] = [
{
groupLabel: "ACCESS CONTROL",
items: [
{ key: "teams", page: "teams", label: "Teams", icon: <Users {...ICON} /> },
{ key: "teams", page: "teams", label: "shell.nav.items.teams", icon: <Users {...ICON} /> },
{
key: "projects",
page: "projects",
label: (
<span className="flex items-center gap-2">
Projects <BetaBadge />
<Trans i18nKey="shell.nav.items.projects" /> <BetaBadge />
</span>
),
icon: <Folder {...ICON} />,
roles: [...projectReaderRoles],
},
{ key: "users", page: "users", label: "Internal Users", icon: <User {...ICON} />, roles: all_admin_roles },
{
key: "users",
page: "users",
label: "shell.nav.items.internalUsers",
icon: <User {...ICON} />,
roles: all_admin_roles,
},
{
key: "organizations",
page: "organizations",
label: "Organizations",
label: "shell.nav.items.organizations",
icon: <Building2 {...ICON} />,
roles: all_admin_roles,
},
{
key: "access-groups",
page: "access-groups",
label: "Access Groups",
label: "shell.nav.items.accessGroups",
icon: <Boxes {...ICON} />,
roles: all_admin_roles,
},
{ key: "budgets", page: "budgets", label: "Budgets", icon: <Wallet {...ICON} />, roles: all_admin_roles },
{
key: "budgets",
page: "budgets",
label: "shell.nav.items.budgets",
icon: <Wallet {...ICON} />,
roles: all_admin_roles,
},
],
},
{
groupLabel: "DEVELOPER TOOLS",
items: [
{ key: "api_ref", page: "api_ref", route: "api-reference", label: "API Reference", icon: <Code2 {...ICON} /> },
{ key: "model-hub-table", page: "model-hub-table", label: "AI Hub", icon: <LayoutGrid {...ICON} /> },
{
key: "api_ref",
page: "api_ref",
route: "api-reference",
label: "shell.nav.items.apiReference",
icon: <Code2 {...ICON} />,
},
{
key: "model-hub-table",
page: "model-hub-table",
label: "shell.nav.items.aiHub",
icon: <LayoutGrid {...ICON} />,
},
{
key: "learning-resources",
page: "learning-resources",
label: "Learning Resources",
label: "shell.nav.items.learningResources",
icon: <BookOpen {...ICON} />,
external_url: "https://models.litellm.ai/cookbook",
},
{
key: "caching",
page: "caching",
label: "Response Cache",
label: "shell.nav.items.responseCache",
icon: <Database {...ICON} />,
roles: all_admin_roles,
},
{
key: "experimental",
page: "experimental",
label: "Experimental",
label: "shell.nav.items.experimental",
icon: <FlaskConical {...ICON} />,
children: [
{
key: "prompts",
page: "prompts",
label: "Prompts",
label: "shell.nav.items.prompts",
icon: <FileText {...ICON} />,
roles: rolesWithCapability("viewPrompts"),
},
{
key: "transform-request",
page: "transform-request",
label: "API Playground",
label: "shell.nav.items.apiPlayground",
icon: <Terminal {...ICON} />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{
key: "tag-management",
page: "tag-management",
label: "Tag Management",
label: "shell.nav.items.tagManagement",
icon: <Tags {...ICON} />,
roles: all_admin_roles,
},
@ -426,43 +462,49 @@ const menuGroups: MenuGroup[] = [
{
key: "settings",
page: "settings",
label: "Settings",
label: "shell.nav.items.settings",
icon: <SettingsIcon {...ICON} />,
roles: all_admin_roles,
children: [
{
key: "router-settings",
page: "router-settings",
label: "Router Settings",
label: "shell.nav.items.routerSettings",
icon: <Route {...ICON} />,
roles: all_admin_roles,
},
{
key: "logging-and-alerts",
page: "logging-and-alerts",
label: "Logging & Alerts",
label: "shell.nav.items.loggingAndAlerts",
icon: <Bell {...ICON} />,
roles: all_admin_roles,
},
{
key: "admin-panel",
page: "admin-panel",
label: "Admin Settings",
label: "shell.nav.items.adminSettings",
icon: <SettingsIcon {...ICON} />,
roles: all_admin_roles,
},
{
key: "cost-tracking",
page: "cost-tracking",
label: "Cost Tracking",
label: "shell.nav.items.costTracking",
icon: <BarChart3 {...ICON} />,
roles: all_admin_roles,
},
{ key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: <Palette {...ICON} />, roles: all_admin_roles },
{
key: "ui-theme",
page: "ui-theme",
label: "shell.nav.items.uiTheme",
icon: <Palette {...ICON} />,
roles: all_admin_roles,
},
{
key: "telemetry",
page: "telemetry",
label: "Telemetry",
label: "shell.nav.items.telemetry",
icon: <Activity {...ICON} />,
roles: proxyAdminTierRoles,
},
@ -498,15 +540,27 @@ const findMenuItemKey = (route: string): string => {
return HOME_ROUTE;
};
const SECTION_DISPLAY: Record<string, string> = {
"AI GATEWAY": "AI Gateway",
OBSERVABILITY: "Observability",
"ACCESS CONTROL": "Access Control",
"DEVELOPER TOOLS": "Developer Tools",
SETTINGS: "Settings",
const GROUP_LABELS: Record<string, ParseKeys> = {
"AI GATEWAY": "shell.nav.groups.aiGateway",
OBSERVABILITY: "shell.nav.groups.observability",
"ACCESS CONTROL": "shell.nav.groups.accessControl",
"DEVELOPER TOOLS": "shell.nav.groups.developerTools",
SETTINGS: "shell.nav.groups.settings",
};
export const sectionText = (groupLabel: string): string => SECTION_DISPLAY[groupLabel] ?? groupLabel;
export const groupText = (groupLabel: string): string =>
Object.hasOwn(GROUP_LABELS, groupLabel) ? i18n.t(GROUP_LABELS[groupLabel]) : groupLabel;
const SECTION_DISPLAY: Record<string, ParseKeys> = {
"AI GATEWAY": "shell.nav.sections.aiGateway",
OBSERVABILITY: "shell.nav.sections.observability",
"ACCESS CONTROL": "shell.nav.sections.accessControl",
"DEVELOPER TOOLS": "shell.nav.sections.developerTools",
SETTINGS: "shell.nav.sections.settings",
};
export const sectionText = (groupLabel: string): string =>
Object.hasOwn(SECTION_DISPLAY, groupLabel) ? i18n.t(SECTION_DISPLAY[groupLabel]) : groupLabel;
const prettify = (key: string): string =>
key
@ -514,7 +568,8 @@ const prettify = (key: string): string =>
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
export const labelText = (item: MenuItem): string => (typeof item.label === "string" ? item.label : prettify(item.key));
export const labelText = (item: MenuItem): string =>
typeof item.label === "string" ? i18n.t(item.label as ParseKeys) : prettify(item.key);
// Breadcrumb ("Section" / "Page") for the top bar, derived from the same nav config.
export const getBreadcrumb = (pathname: string): { section: string | null; title: string } => {
@ -542,8 +597,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
allowVectorStoresForTeamAdmins,
}) => {
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
const { t } = useTranslation();
const term = useTerminology();
const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(item.label) : item.label);
const labelOf = (item: MenuItem) => (typeof item.label === "string" ? term(labelText(item)) : item.label);
const isOrgAdmin = useIsOrgAdmin();
const { data: teams } = useTeams();
const logout = useLogout(accessToken);
@ -686,7 +742,12 @@ const Sidebar_: React.FC<SidebarProps> = ({
return (
<Sidebar collapsed={collapsed}>
<SidebarBrand accessToken={accessToken} collapsed={collapsed} onToggleCollapsed={onToggleCollapsed} />
<SidebarBrand
accessToken={accessToken}
homeLabel={t("shell.nav.homeLink")}
collapsed={collapsed}
onToggleCollapsed={onToggleCollapsed}
/>
<div className="space-y-1 px-3 pt-3 pb-1">
<ViewSwitcher className="h-9 w-full max-w-none justify-start shadow-none group-data-[collapsed=true]/sidebar:size-9 group-data-[collapsed=true]/sidebar:justify-center group-data-[collapsed=true]/sidebar:p-0 [&_[data-slot=workspace-label]]:flex-1 [&_[data-slot=workspace-label]]:text-left group-data-[collapsed=true]/sidebar:[&_[data-slot=workspace-label]]:hidden" />
@ -697,7 +758,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
{visibleGroups.map((group, gi) => (
<SidebarGroup key={group.groupLabel}>
{gi > 0 && <SidebarSeparator className="hidden group-data-[collapsed=true]/sidebar:block" />}
<SidebarGroupLabel>{term(group.groupLabel)}</SidebarGroupLabel>
<SidebarGroupLabel>{term(groupText(group.groupLabel))}</SidebarGroupLabel>
<SidebarMenu>{group.items.map((item) => renderItem(item))}</SidebarMenu>
</SidebarGroup>
))}

View file

@ -1,3 +1,5 @@
import type { ParseKeys } from "i18next";
// Must match the backend SpecialMCPServerNames.no_mcp_servers enum value.
export const NO_MCP_SERVERS_SENTINEL = "no-mcp-servers";
@ -6,5 +8,4 @@ export const ALL_PROXY_MCP_SERVERS_SENTINEL = "all-proxy-mcpservers";
// Must match the backend MCP_ALL_TOOLS_WILDCARD constant in litellm/constants.py.
export const MCP_ALL_TOOLS_WILDCARD = "*";
export const MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE =
"Tool preview is not available for submissions. Tools will be verified by an admin during review.";
export const MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE: ParseKeys = "mcpTools.previewForbidden";

View file

@ -1,3 +1,5 @@
import type { ParseKeys } from "i18next";
/** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */
export interface MCPEvent {
type: string;
@ -47,19 +49,19 @@ export const AUTH_TYPE = {
OAUTH_DELEGATE: "oauth_delegate",
};
export const AUTH_TYPE_ITEMS = [
{ value: AUTH_TYPE.NONE, label: "None" },
{ value: AUTH_TYPE.API_KEY, label: "API Key" },
{ value: AUTH_TYPE.BEARER_TOKEN, label: "Bearer Token" },
{ value: AUTH_TYPE.TOKEN, label: "Token" },
{ value: AUTH_TYPE.BASIC, label: "Basic Auth" },
{ value: AUTH_TYPE.AUTHORIZATION, label: "Authorization (raw header)" },
{ value: AUTH_TYPE.OAUTH2, label: "OAuth" },
{ value: AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, label: "OAuth Token Exchange (OBO)" },
{ value: AUTH_TYPE.OAUTH2_ID_JAG, label: "ID-JAG (Okta Cross App Access)" },
{ value: AUTH_TYPE.AWS_SIGV4, label: "AWS SigV4 (Bedrock AgentCore MCPs)" },
{ value: AUTH_TYPE.TRUE_PASSTHROUGH, label: "True Passthrough (no LiteLLM auth)" },
{ value: AUTH_TYPE.OAUTH_DELEGATE, label: "OAuth Delegate (client-supplied upstream token)" },
export const AUTH_TYPE_ITEMS: { value: string; label: ParseKeys }[] = [
{ value: AUTH_TYPE.NONE, label: "mcpTools.authTypes.none" },
{ value: AUTH_TYPE.API_KEY, label: "mcpTools.authTypes.apiKey" },
{ value: AUTH_TYPE.BEARER_TOKEN, label: "mcpTools.authTypes.bearerToken" },
{ value: AUTH_TYPE.TOKEN, label: "mcpTools.authTypes.token" },
{ value: AUTH_TYPE.BASIC, label: "mcpTools.authTypes.basic" },
{ value: AUTH_TYPE.AUTHORIZATION, label: "mcpTools.authTypes.authorization" },
{ value: AUTH_TYPE.OAUTH2, label: "mcpTools.authTypes.oauth2" },
{ value: AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, label: "mcpTools.authTypes.oauth2TokenExchange" },
{ value: AUTH_TYPE.OAUTH2_ID_JAG, label: "mcpTools.authTypes.oauth2IdJag" },
{ value: AUTH_TYPE.AWS_SIGV4, label: "mcpTools.authTypes.awsSigv4" },
{ value: AUTH_TYPE.TRUE_PASSTHROUGH, label: "mcpTools.authTypes.truePassthrough" },
{ value: AUTH_TYPE.OAUTH_DELEGATE, label: "mcpTools.authTypes.oauthDelegate" },
];
// The two client-forwarded token modes: the caller supplies the upstream Authorization (forwarded
@ -279,11 +281,11 @@ export const TRANSPORT = {
OPENAPI: "openapi",
};
export const TRANSPORT_ITEMS = [
{ value: TRANSPORT.HTTP, label: "Streamable HTTP (Recommended)" },
{ value: TRANSPORT.SSE, label: "Server-Sent Events (SSE)" },
{ value: TRANSPORT.STDIO, label: "Standard Input/Output (stdio)" },
{ value: TRANSPORT.OPENAPI, label: "OpenAPI Spec" },
export const TRANSPORT_ITEMS: { value: string; label: ParseKeys }[] = [
{ value: TRANSPORT.HTTP, label: "mcpTools.transports.http" },
{ value: TRANSPORT.SSE, label: "mcpTools.transports.sse" },
{ value: TRANSPORT.STDIO, label: "mcpTools.transports.stdio" },
{ value: TRANSPORT.OPENAPI, label: "mcpTools.transports.openapi" },
];
export const handleTransport = (transport?: string | null, specPath?: string | null): string => {
@ -422,7 +424,7 @@ export interface MCPToolsViewerProps {
extraHeaders?: string[] | null;
}
export const MCP_REACHABLE_DESCRIPTION = "Server responded. Authentication and tools were not checked";
export const MCP_REACHABLE_DESCRIPTION: ParseKeys = "mcpTools.reachableDescription";
export interface MCPServer {
server_id: string;

View file

@ -2029,7 +2029,9 @@ export const testConnectionRequest = async (
} else {
return {
status: "error",
message: data.error?.message || `Connection test failed: ${response.status} ${response.statusText}`,
message:
data.error?.message ||
i18n.t("networking.connectionTestFailed", { status: response.status, statusText: response.statusText }),
};
}
}
@ -4875,7 +4877,7 @@ export const listMCPTools = async (
return {
tools: [],
error: "network_error",
message: error instanceof Error ? error.message : "Failed to fetch MCP tools",
message: error instanceof Error ? error.message : i18n.t("networking.mcpToolsFetchFailed"),
stack_trace: null,
};
}
@ -4888,7 +4890,7 @@ export const listMCPTools = async (
return {
tools: [],
error: "parse_error",
message: "Failed to parse MCP tools response",
message: i18n.t("networking.mcpToolsParseFailed"),
status: response.status,
statusText: response.statusText,
stack_trace: null,
@ -4901,7 +4903,7 @@ export const listMCPTools = async (
// to inspect `result.error` / `result.message`. Attach `status` so
// callers that need to react to auth failures (e.g. the useQuery in
// mcp_tools.tsx) can still detect 401s from the returned object.
const errorMessage = (data && (data.message || data.error)) || "Failed to fetch MCP tools";
const errorMessage = (data && (data.message || data.error)) || i18n.t("networking.mcpToolsFetchFailed");
return {
tools: [],
error: (data && data.error) || `http_${response.status}`,
@ -6338,7 +6340,7 @@ export const testMCPToolsListRequest = async (
tools: [],
error: true,
status: 403,
message: MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE,
message: i18n.t(MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE),
};
}
// Return the error response instead of throwing an error
@ -6350,7 +6352,9 @@ export const testMCPToolsListRequest = async (
tools: [],
error: "request_failed",
status: response.status,
message: data.message || `MCP tools list failed: ${response.status} ${response.statusText}`,
message:
data.message ||
i18n.t("networking.mcpToolsListFailed", { status: response.status, statusText: response.statusText }),
};
}

View file

@ -3,49 +3,51 @@
* This file contains descriptions and metadata for all navigation pages
*/
import type { ParseKeys } from "i18next";
// Page descriptions for UI Settings configuration
export const pageDescriptions: Record<string, string> = {
home: "Usage at a glance, recent launches and blog updates",
"api-keys": "Manage virtual keys for API access and authentication",
"llm-playground": "Interactive playground for testing LLM requests",
models: "Configure and manage LLM models and endpoints",
agents: "Create and manage AI agents",
agentic: "Manage agentic resources: agents, workflow runs, and memory",
workflows: "Track and inspect durable workflow run history",
"mcp-servers": "Configure Model Context Protocol servers",
memory: "Inspect and manage agent memory entries stored under /v1/memory",
guardrails: "Set up content moderation and safety guardrails",
policies: "Define access control and usage policies",
"search-tools": "Configure RAG search and retrieval tools",
"tool-policies": "Configure tool use policies and permissions",
"vector-stores": "Manage vector databases for embeddings",
new_usage: "View usage analytics and metrics",
alerts: "Review and resolve model deprecation alerts across keys and teams",
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
"model-insights": "Model Leaderboard: compare usage, spend, tokens, and task mix across this gateway",
logs: "Access request and response logs",
lens: "Review agent activity and investigate patterns with supporting evidence",
"guardrails-monitor": "Monitor guardrail performance and view logs",
users: "Manage internal user accounts and permissions",
teams: "Create and manage teams for access control",
organizations: "Manage organizations and their members",
projects: "Manage projects within teams",
"access-groups": "Manage access groups for role-based permissions",
budgets: "Set and monitor spending budgets",
api_ref: "Browse API documentation and endpoints",
"model-hub-table": "Explore available AI models and providers",
"learning-resources": "Access tutorials and documentation",
caching: "Configure response caching and coordination Redis settings",
"transform-request": "Set up request transformation rules",
"cost-tracking": "Track and analyze API costs",
"ui-theme": "Customize dashboard appearance",
"tag-management": "Organize resources with tags",
prompts: "Manage and version prompt templates",
skills: "Browse and manage Claude Code skills",
"router-settings": "Configure routing and load balancing settings",
telemetry: "Choose which anonymous usage telemetry this proxy collects and see the last report",
"logging-and-alerts": "Set up logging and alert configurations",
"admin-panel": "Access admin panel and settings",
export const pageDescriptions: Record<string, ParseKeys> = {
home: "shell.pageDescriptions.home",
"api-keys": "shell.pageDescriptions.apiKeys",
"llm-playground": "shell.pageDescriptions.llmPlayground",
models: "shell.pageDescriptions.models",
agents: "shell.pageDescriptions.agents",
agentic: "shell.pageDescriptions.agentic",
workflows: "shell.pageDescriptions.workflows",
"mcp-servers": "shell.pageDescriptions.mcpServers",
memory: "shell.pageDescriptions.memory",
guardrails: "shell.pageDescriptions.guardrails",
policies: "shell.pageDescriptions.policies",
"search-tools": "shell.pageDescriptions.searchTools",
"tool-policies": "shell.pageDescriptions.toolPolicies",
"vector-stores": "shell.pageDescriptions.vectorStores",
new_usage: "shell.pageDescriptions.newUsage",
alerts: "shell.pageDescriptions.alerts",
"cost-optimization": "shell.pageDescriptions.costOptimization",
"model-insights": "shell.pageDescriptions.modelInsights",
logs: "shell.pageDescriptions.logs",
lens: "shell.pageDescriptions.lens",
"guardrails-monitor": "shell.pageDescriptions.guardrailsMonitor",
users: "shell.pageDescriptions.users",
teams: "shell.pageDescriptions.teams",
organizations: "shell.pageDescriptions.organizations",
projects: "shell.pageDescriptions.projects",
"access-groups": "shell.pageDescriptions.accessGroups",
budgets: "shell.pageDescriptions.budgets",
api_ref: "shell.pageDescriptions.apiRef",
"model-hub-table": "shell.pageDescriptions.modelHubTable",
"learning-resources": "shell.pageDescriptions.learningResources",
caching: "shell.pageDescriptions.caching",
"transform-request": "shell.pageDescriptions.transformRequest",
"cost-tracking": "shell.pageDescriptions.costTracking",
"ui-theme": "shell.pageDescriptions.uiTheme",
"tag-management": "shell.pageDescriptions.tagManagement",
prompts: "shell.pageDescriptions.prompts",
skills: "shell.pageDescriptions.skills",
"router-settings": "shell.pageDescriptions.routerSettings",
telemetry: "shell.pageDescriptions.telemetry",
"logging-and-alerts": "shell.pageDescriptions.loggingAndAlerts",
"admin-panel": "shell.pageDescriptions.adminPanel",
};
export interface PageMetadata {

View file

@ -2,7 +2,8 @@
* Utility functions for working with navigation pages
*/
import { labelText, menuGroups } from "./leftnav";
import i18n from "@/i18n/i18n";
import { groupText, labelText, menuGroups } from "./leftnav";
import { pageDescriptions, PageMetadata } from "./page_metadata";
import { internalUserRoles } from "@/utils/roles";
@ -46,8 +47,8 @@ export const getAvailablePages = (): PageMetadata[] => {
pages.push({
page: item.page,
label: labelText(item),
group: group.groupLabel,
description: pageDescriptions[item.page] || "No description available",
group: groupText(group.groupLabel),
description: pageDescriptions[item.page] ? i18n.t(pageDescriptions[item.page]) : "No description available",
});
}
@ -60,8 +61,10 @@ export const getAvailablePages = (): PageMetadata[] => {
pages.push({
page: child.page,
label: labelText(child),
group: `${group.groupLabel} > ${parentLabel}`,
description: pageDescriptions[child.page] || "No description available",
group: `${groupText(group.groupLabel)} > ${parentLabel}`,
description: pageDescriptions[child.page]
? i18n.t(pageDescriptions[child.page])
: "No description available",
});
}
});

View file

@ -95,13 +95,17 @@ export const agentCapabilities = (agent: AgentCard): string[] =>
export type HealthTone = "success" | "error" | "neutral";
const HEALTH: Record<string, { tone: HealthTone; label: string }> = {
healthy: { tone: "success", label: "Healthy" },
unhealthy: { tone: "error", label: "Unhealthy" },
const HEALTH: Record<string, { tone: HealthTone; label: ParseKeys }> = {
healthy: { tone: "success", label: "publicModelHub.format.health.healthy" },
unhealthy: { tone: "error", label: "publicModelHub.format.health.unhealthy" },
};
export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: string } =>
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: i18n.t("publicModelHub.format.notChecked") };
export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: string } => {
const status = model.health_status ?? "";
return Object.hasOwn(HEALTH, status)
? { tone: HEALTH[status].tone, label: i18n.t(HEALTH[status].label) }
: { tone: "neutral", label: i18n.t("publicModelHub.format.notChecked") };
};
export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => {
const then = iso ? new Date(iso).getTime() : NaN;

View file

@ -2,6 +2,7 @@
import type { ComponentProps } from "react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/cva.config";
@ -15,17 +16,18 @@ export type DurationInputProps = Omit<ComponentProps<"div">, "onChange"> & {
};
export function DurationInput({ label, value, onChange, base, max, className, ...props }: DurationInputProps) {
const { t } = useTranslation();
const id = useId();
const units =
base === "minutes"
? [
{ label: "minutes", scale: 1 },
{ label: "hours", scale: 60 },
{ label: "days", scale: 1440 },
{ label: t("shared.durationInput.units.minutes"), scale: 1 },
{ label: t("shared.durationInput.units.hours"), scale: 60 },
{ label: t("shared.durationInput.units.days"), scale: 1440 },
]
: [
{ label: "hours", scale: 1 },
{ label: "days", scale: 24 },
{ label: t("shared.durationInput.units.hours"), scale: 1 },
{ label: t("shared.durationInput.units.days"), scale: 24 },
];
const [scale, setScale] = useState(() => [...units].reverse().find((unit) => value % unit.scale === 0)?.scale ?? 1);
function changeUnit(next: number) {
@ -48,7 +50,7 @@ export function DurationInput({ label, value, onChange, base, max, className, ..
/>
<div className="relative w-28 shrink-0">
<select
aria-label={`${label} unit`}
aria-label={t("shared.durationInput.unitLabel", { label })}
value={scale}
className="h-9 w-full appearance-none rounded-md border border-input bg-background pl-3 pr-9 text-sm"
onChange={(event) => changeUnit(Number(event.target.value))}

View file

@ -1,5 +1,6 @@
"use client";
import type { ParseKeys, TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { SimpleTooltip } from "@/components/ui/tooltip";
@ -141,8 +142,15 @@ export const keyOwnerBudgetSource = (
applyUserBudgetToTeamKeys: boolean,
): UserBudgetSource | null => (!key.team_id || applyUserBudgetToTeamKeys ? key.user ?? null : null);
const formatGate = (gate: InheritedBudgetGate): string =>
`${gate.scope} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;
const SCOPE_LABELS: Record<InheritedBudgetGate["scope"], ParseKeys> = {
Team: "shared.inheritedBudgetHint.scopes.team",
"Team member": "shared.inheritedBudgetHint.scopes.teamMember",
Organization: "shared.inheritedBudgetHint.scopes.organization",
User: "shared.inheritedBudgetHint.scopes.user",
};
const formatGate = (gate: InheritedBudgetGate, t: TFunction): string =>
`${t(SCOPE_LABELS[gate.scope])} ${gate.alias}: $${formatNumberWithCommas(gate.maxBudget, 2)}${gate.budgetDuration ? ` / ${gate.budgetDuration}` : ""}`;
interface InheritedBudgetHintProps {
gates: readonly InheritedBudgetGate[];
@ -157,7 +165,7 @@ export function InheritedBudgetHint({ gates }: InheritedBudgetHintProps) {
<div data-testid="inherited-budget-hint" className="flex flex-col gap-1">
<span>{t("shared.inheritedBudgetHint.intro")}</span>
{gates.map((gate) => (
<span key={gate.scope}>{formatGate(gate)}</span>
<span key={gate.scope}>{formatGate(gate, t)}</span>
))}
</div>
}

View file

@ -15,6 +15,7 @@ import {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage, useMediaQuery, useOnClickOutside, useWindowSize } from "usehooks-ts";
import { ShortcutHints } from "@/components/shared/ShortcutHints";
@ -185,6 +186,7 @@ function useExitPresence<T>(item: T | null, itemKey: (item: T) => string) {
}
function ResizeHandle({ noun, width, onResize }: { noun: string; width: number; onResize: (width: number) => void }) {
const { t } = useTranslation();
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
@ -207,7 +209,7 @@ function ResizeHandle({ noun, width, onResize }: { noun: string; width: number;
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${noun} panel`}
aria-label={t("shared.inspector.resizePanel", { noun })}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
@ -254,17 +256,22 @@ function HeaderButton({
}
function Header() {
const { t } = useTranslation();
const { index, total, noun, step, close, fullScreen, setFullScreen } = useInspector();
return (
<div data-slot="inspector-header" className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={close}>
<HeaderButton label={t("shared.inspector.close")} onClick={close}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label={`Next ${noun} (J)`} disabled={index < 0 || index >= total - 1} onClick={() => step(1)}>
<HeaderButton
label={t("shared.inspector.next", { noun })}
disabled={index < 0 || index >= total - 1}
onClick={() => step(1)}
>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label={`Previous ${noun} (K)`} disabled={index <= 0} onClick={() => step(-1)}>
<HeaderButton label={t("shared.inspector.previous", { noun })} disabled={index <= 0} onClick={() => step(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
@ -274,12 +281,12 @@ function Header() {
)}
<div className="ml-auto flex items-center gap-1">
<HeaderButton
label={fullScreen ? "Exit full screen" : "Enter full screen"}
label={fullScreen ? t("shared.inspector.exitFullScreen") : t("shared.inspector.enterFullScreen")}
onClick={() => setFullScreen(!fullScreen)}
>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
<HeaderButton label={`Close ${noun} (Esc)`} onClick={close}>
<HeaderButton label={t("shared.inspector.closeNoun", { noun })} onClick={close}>
<X className="size-4" />
</HeaderButton>
</div>

View file

@ -8,6 +8,7 @@ import { ChevronRight } from "lucide-react";
import { createContext, Fragment, useContext, useState, type ComponentProps, type ReactNode } from "react";
import { Inspector } from "@/components/shared/Inspector";
import i18n from "@/i18n/i18n";
import { Skeleton } from "@/components/ui/skeleton";
import { TableBody, TableCell, TableHead, TableHeader } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
@ -176,7 +177,12 @@ interface IndentProps<T> {
readonly className?: string;
}
function Indent<T>({ row, toggleLabel = (expanded) => (expanded ? "Collapse" : "Expand"), className }: IndentProps<T>) {
function Indent<T>({
row,
toggleLabel = (expanded) =>
expanded ? i18n.t("shared.inspectorTable.collapse") : i18n.t("shared.inspectorTable.expand"),
className,
}: IndentProps<T>) {
if (row.depth > 0) {
const last = row.getParentRow()?.subRows.at(-1)?.id === row.id;
return (

View file

@ -2,6 +2,7 @@
import { Fragment } from "react";
import { useHotkeysContext } from "react-hotkeys-hook";
import { useTranslation } from "react-i18next";
import { Kbd } from "@/components/ui/kbd";
import { cn } from "@/lib/cva.config";
@ -62,12 +63,13 @@ export function shortcutHints(shortcuts: readonly BoundShortcut[]): readonly Sho
/** The shortcuts active right now, read from what is bound rather than typed by hand. */
export function ShortcutHints({ className }: { className?: string }) {
const { t } = useTranslation();
const { hotkeys } = useHotkeysContext();
const hints = shortcutHints(hotkeys);
if (hints.length === 0) return null;
return (
<div
aria-label="Keyboard shortcuts"
aria-label={t("shared.shortcutHints.label")}
data-slot="shortcut-hints"
className={cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", className)}
>

View file

@ -312,7 +312,7 @@ const AdvancedDatePicker: React.FC<AdvancedDatePickerProps> = ({
onClick={() => handleRelativeTimeSelect(option)}
>
<span className={`text-sm ${isSelected ? "text-info font-medium" : "text-foreground"}`}>
{option.label}
{t(option.label)}
</span>
<span
className={`text-xs px-2 py-1 rounded capitalize ${

View file

@ -1,7 +1,12 @@
import type { ParseKeys } from "i18next";
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
import type { CredentialItem } from "@/components/networking";
import i18n from "@/i18n/i18n";
export const NO_CREDENTIAL_OPTION: SearchSelectOption = { label: "None", value: "" };
const NO_CREDENTIAL_LABEL: ParseKeys = "shared.credentialOptions.none";
export const NO_CREDENTIAL_OPTION: SearchSelectOption = { label: NO_CREDENTIAL_LABEL, value: "" };
export const credentialLabel = (credential: CredentialItem): string =>
credential.display_name || credential.credential_name;
@ -16,6 +21,6 @@ export const toCredentialOption = (credential: CredentialItem): SearchSelectOpti
});
export const credentialOptions = (credentials: CredentialItem[]): SearchSelectOption[] => [
NO_CREDENTIAL_OPTION,
{ ...NO_CREDENTIAL_OPTION, label: i18n.t(NO_CREDENTIAL_LABEL) },
...credentials.map(toCredentialOption),
];

View file

@ -1,7 +1,8 @@
import type { ParseKeys } from "i18next";
import moment from "moment";
export interface RelativeTimeOption {
label: string;
label: ParseKeys;
shortLabel: string;
settingValue: string;
getValue: () => { from: Date; to: Date };
@ -9,7 +10,7 @@ export interface RelativeTimeOption {
export const relativeTimeOptions: readonly RelativeTimeOption[] = [
{
label: "Today",
label: "shared.dateRangePresets.today",
shortLabel: "today",
settingValue: "today",
getValue: () => ({
@ -18,7 +19,7 @@ export const relativeTimeOptions: readonly RelativeTimeOption[] = [
}),
},
{
label: "Last 7 days",
label: "shared.dateRangePresets.last7Days",
shortLabel: "7d",
settingValue: "last_7_days",
getValue: () => ({
@ -27,7 +28,7 @@ export const relativeTimeOptions: readonly RelativeTimeOption[] = [
}),
},
{
label: "Last 30 days",
label: "shared.dateRangePresets.last30Days",
shortLabel: "30d",
settingValue: "last_30_days",
getValue: () => ({
@ -36,7 +37,7 @@ export const relativeTimeOptions: readonly RelativeTimeOption[] = [
}),
},
{
label: "Month to date",
label: "shared.dateRangePresets.monthToDate",
shortLabel: "MTD",
settingValue: "month_to_date",
getValue: () => ({
@ -45,7 +46,7 @@ export const relativeTimeOptions: readonly RelativeTimeOption[] = [
}),
},
{
label: "Year to date",
label: "shared.dateRangePresets.yearToDate",
shortLabel: "YTD",
settingValue: "year_to_date",
getValue: () => ({

View file

@ -3,12 +3,14 @@
import "prosemirror-view/style/prosemirror.css";
import { useDebouncer } from "@tanstack/react-pacer/debouncer";
import type { ParseKeys } from "i18next";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import { Check, Copy, CornerDownLeft, Loader2, type LucideIcon, Search } from "lucide-react";
import { Schema } from "prosemirror-model";
import { EditorState, Plugin, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import { type ComponentProps, createContext, useContext, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
@ -24,11 +26,11 @@ const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } });
const EMIT_WAIT_MS = 150;
const KEEP_MENU_CLOSED = "searchBoxKeepMenuClosed";
const OPERATORS: readonly { readonly op: FilterOp; readonly label: string; readonly example: string }[] = [
{ op: "eq", label: "equals", example: "foo:bar" },
{ op: "neq", label: "not equals", example: "-foo:bar" },
{ op: "glob", label: "wildcard match", example: "foo:*bar*" },
{ op: "nglob", label: "does not contain", example: "-foo:*bar*" },
const OPERATORS: readonly { readonly op: FilterOp; readonly label: ParseKeys; readonly example: string }[] = [
{ op: "eq", label: "shared.searchBox.operators.equals", example: "foo:bar" },
{ op: "neq", label: "shared.searchBox.operators.notEquals", example: "-foo:bar" },
{ op: "glob", label: "shared.searchBox.operators.wildcardMatch", example: "foo:*bar*" },
{ op: "nglob", label: "shared.searchBox.operators.doesNotContain", example: "-foo:*bar*" },
];
/** Colours each `key:` so the filters stand out from free text. */
@ -239,6 +241,7 @@ export type SearchBoxInputProps = ComponentProps<"div"> & {
/** The bordered field holding the editor; shows `placeholder` while the query is empty. */
function Input({ placeholder, busy = false, className, ...props }: SearchBoxInputProps) {
const { t } = useTranslation();
const { text } = useSearchBox();
return (
<div
@ -252,7 +255,7 @@ function Input({ placeholder, busy = false, className, ...props }: SearchBoxInpu
{busy ? (
<Loader2
role="status"
aria-label="Loading results"
aria-label={t("shared.searchBox.loadingResults")}
className="size-3.5 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
) : (
@ -277,6 +280,7 @@ export type SearchBoxSuggestionsProps = ComponentProps<"div">;
* `children` render in the footer beside the key hints, e.g. a `CopyCommand`.
*/
function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProps) {
const { t } = useTranslation();
const { listId, menu, activeId, icon, ops } = useSearchBox();
const pick = useEditorEventCallback((view, item: Suggestion<string>) => applySuggestion(view, item));
if (!menu) return null;
@ -289,7 +293,12 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp
)}
{...props}
>
<div id={listId} role="listbox" aria-label="Search suggestions" className="max-h-80 overflow-y-auto p-1">
<div
id={listId}
role="listbox"
aria-label={t("shared.searchBox.suggestions")}
className="max-h-80 overflow-y-auto p-1"
>
{menu.groups.map((group) => (
<div key={group.heading} role="group" aria-label={group.heading}>
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">{group.heading}</div>
@ -314,7 +323,7 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp
))}
{menu.loading && (
<div role="status" className="px-2 py-1.5 font-mono text-xs text-muted-foreground">
Loading values…
{t("shared.searchBox.loadingValues")}
</div>
)}
{menu.showOperators && <OperatorHints ops={ops} />}
@ -323,13 +332,13 @@ function Suggestions({ className, children, ...props }: SearchBoxSuggestionsProp
<span className="flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
Navigate
{t("shared.searchBox.navigate")}
</span>
<span className="flex items-center gap-1">
<Kbd>
<CornerDownLeft />
</Kbd>
Select
{t("shared.searchBox.select")}
</span>
{children}
</div>
@ -346,13 +355,14 @@ export type SearchBoxCopyCommandProps<F extends string> = {
type CopyOutcome = { readonly command: string; readonly outcome: "copied" | "failed" } | null;
const copyLabel = (last: CopyOutcome, command: string): string => {
if (last?.command !== command) return "Copy as curl";
return last.outcome === "copied" ? "Copied" : "Copy failed";
const copyLabel = (last: CopyOutcome, command: string): ParseKeys => {
if (last?.command !== command) return "shared.searchBox.copyCommand.copy";
return last.outcome === "copied" ? "shared.searchBox.copyCommand.copied" : "shared.searchBox.copyCommand.failed";
};
/** Puts the current query on the clipboard as an API call, from the suggestions footer. */
function CopyCommand<F extends string>({ title, command }: SearchBoxCopyCommandProps<F>) {
const { t } = useTranslation();
const { clauses } = useSearchBox();
const [last, setLast] = useState<CopyOutcome>(null);
const current = useMemo(() => command(toSearchQuery(clauses as readonly QueryClause<F>[])), [command, clauses]);
@ -373,19 +383,20 @@ function CopyCommand<F extends string>({ title, command }: SearchBoxCopyCommandP
onMouseDown={(event) => event.preventDefault()}
onClick={() => void copy()}
>
{label === "Copied" ? <Check /> : <Copy />}
{label}
{label === "shared.searchBox.copyCommand.copied" ? <Check /> : <Copy />}
{t(label)}
</Button>
);
}
function OperatorHints({ ops }: { ops: readonly FilterOp[] }) {
const { t } = useTranslation();
return (
<div className="pb-1">
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">Comparison operators</div>
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">{t("shared.searchBox.comparisonOperators")}</div>
{OPERATORS.filter((op) => ops.includes(op.op)).map((op) => (
<div key={op.label} className="flex items-center justify-between px-2 py-1 font-mono text-xs">
<span className="text-warning">{op.label}</span>
<span className="text-warning">{t(op.label)}</span>
<code className="rounded bg-muted px-1.5 text-muted-foreground">{op.example}</code>
</div>
))}

View file

@ -1,8 +1,25 @@
"use client";
import type { ParseKeys } from "i18next";
import i18n from "@/i18n/i18n";
import { CellTooltip } from "./cell_tooltip";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
const MONTHS: readonly ParseKeys[] = [
"shared.tableCells.dateCell.months.jan",
"shared.tableCells.dateCell.months.feb",
"shared.tableCells.dateCell.months.mar",
"shared.tableCells.dateCell.months.apr",
"shared.tableCells.dateCell.months.may",
"shared.tableCells.dateCell.months.jun",
"shared.tableCells.dateCell.months.jul",
"shared.tableCells.dateCell.months.aug",
"shared.tableCells.dateCell.months.sep",
"shared.tableCells.dateCell.months.oct",
"shared.tableCells.dateCell.months.nov",
"shared.tableCells.dateCell.months.dec",
];
export type DatePrecision = "datetime" | "date";
@ -16,12 +33,12 @@ const pad = (n: number): string => String(n).padStart(2, "0");
export const formatCellDate = (date: Date, precision: DatePrecision): string =>
precision === "date"
? `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`
: `${MONTHS[date.getMonth()]} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
? `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`
: `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
export const formatFullTimestamp = (date: Date): string => {
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const day = `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
const day = `${i18n.t(MONTHS[date.getMonth()])} ${date.getDate()}, ${date.getFullYear()}`;
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${day}, ${time} (${timeZone})`;
};

View file

@ -3,6 +3,7 @@
import { X } from "lucide-react";
import moment from "moment";
import { useRef, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import { useResizeObserver } from "usehooks-ts";
import { DotFieldCanvas, DotFieldRoot } from "@/components/shared/dotField/DotField";
@ -85,6 +86,7 @@ function BucketTooltip({
bucketCount: number;
noun: ItemNoun;
}) {
const { t } = useTranslation();
return (
<div
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
@ -92,13 +94,16 @@ function BucketTooltip({
role="tooltip"
>
<div>
{moment(bucket.startMs).format(EDGE_FORMAT)} to {moment(bucket.endMs).format("HH:mm")}
{t("shared.timeline.tooltip.range", {
start: moment(bucket.startMs).format(EDGE_FORMAT),
end: moment(bucket.endMs).format("HH:mm"),
})}
</div>
<div>
{bucket.total} {bucket.total === 1 ? noun.singular : noun.plural}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
{bucket.failed > 0 && t("shared.timeline.tooltip.failed", { count: bucket.failed })}
</div>
<div className="text-info">drag to zoom</div>
<div className="text-info">{t("shared.timeline.tooltip.dragToZoom")}</div>
</div>
);
}
@ -228,6 +233,7 @@ function TickAxis({ range, width }: { range: TimeWindow; width: number }) {
/** Histogram over the window covered by `buckets`. Drag to select; drag the bracket or its edges to adjust; Esc clears. */
export function Timeline({ buckets, selection, onSelect, noun }: TimelineProps) {
const { t } = useTranslation();
const bucketCount = buckets.length;
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
@ -340,7 +346,7 @@ export function Timeline({ buckets, selection, onSelect, noun }: TimelineProps)
<button
type="button"
onClick={() => onSelect(null)}
aria-label="Clear time zoom"
aria-label={t("shared.timeline.clearZoom")}
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />

View file

@ -7208,6 +7208,10 @@
"lastChecked": "Last checked"
},
"format": {
"health": {
"healthy": "Healthy",
"unhealthy": "Unhealthy"
},
"modes": {
"chat": "Chat",
"completion": "Completion",
@ -7423,6 +7427,157 @@
}
},
"shell": {
"nav": {
"groups": {
"aiGateway": "AI GATEWAY",
"observability": "OBSERVABILITY",
"accessControl": "ACCESS CONTROL",
"developerTools": "DEVELOPER TOOLS",
"settings": "SETTINGS"
},
"sections": {
"aiGateway": "AI Gateway",
"observability": "Observability",
"accessControl": "Access Control",
"developerTools": "Developer Tools",
"settings": "Settings"
},
"items": {
"home": "Home",
"virtualKeys": "Virtual Keys",
"playground": "Playground",
"modelsAndEndpoints": "Models + Endpoints",
"agentic": "Agentic",
"agents": "Agents",
"workflowRuns": "Workflow Runs",
"memory": "Memory",
"mcpServers": "MCP Servers",
"skills": "Skills",
"guardrails": "Guardrails",
"policies": "Policies",
"tools": "Tools",
"searchTools": "Search Tools",
"vectorStores": "Vector Stores",
"toolPolicies": "Tool Policies",
"usage": "Usage",
"alerts": "Alerts",
"modelLeaderboard": "Model Leaderboard",
"costOptimization": "Cost Optimization",
"logs": "Logs",
"lens": "Lens",
"guardrailsMonitor": "Guardrails Monitor",
"teams": "Teams",
"projects": "Projects",
"internalUsers": "Internal Users",
"organizations": "Organizations",
"accessGroups": "Access Groups",
"budgets": "Budgets",
"apiReference": "API Reference",
"aiHub": "AI Hub",
"learningResources": "Learning Resources",
"responseCache": "Response Cache",
"experimental": "Experimental",
"prompts": "Prompts",
"apiPlayground": "API Playground",
"tagManagement": "Tag Management",
"settings": "Settings",
"routerSettings": "Router Settings",
"loggingAndAlerts": "Logging & Alerts",
"adminSettings": "Admin Settings",
"costTracking": "Cost Tracking",
"uiTheme": "UI Theme",
"telemetry": "Telemetry"
},
"homeLink": "LiteLLM home"
},
"pageDescriptions": {
"home": "Usage at a glance, recent launches and blog updates",
"apiKeys": "Manage virtual keys for API access and authentication",
"llmPlayground": "Interactive playground for testing LLM requests",
"models": "Configure and manage LLM models and endpoints",
"agents": "Create and manage AI agents",
"agentic": "Manage agentic resources: agents, workflow runs, and memory",
"workflows": "Track and inspect durable workflow run history",
"mcpServers": "Configure Model Context Protocol servers",
"memory": "Inspect and manage agent memory entries stored under /v1/memory",
"guardrails": "Set up content moderation and safety guardrails",
"policies": "Define access control and usage policies",
"searchTools": "Configure RAG search and retrieval tools",
"toolPolicies": "Configure tool use policies and permissions",
"vectorStores": "Manage vector databases for embeddings",
"newUsage": "View usage analytics and metrics",
"alerts": "Review and resolve model deprecation alerts across keys and teams",
"costOptimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
"modelInsights": "Model Leaderboard: compare usage, spend, tokens, and task mix across this gateway",
"logs": "Access request and response logs",
"lens": "Review agent activity and investigate patterns with supporting evidence",
"guardrailsMonitor": "Monitor guardrail performance and view logs",
"users": "Manage internal user accounts and permissions",
"teams": "Create and manage teams for access control",
"organizations": "Manage organizations and their members",
"projects": "Manage projects within teams",
"accessGroups": "Manage access groups for role-based permissions",
"budgets": "Set and monitor spending budgets",
"apiRef": "Browse API documentation and endpoints",
"modelHubTable": "Explore available AI models and providers",
"learningResources": "Access tutorials and documentation",
"caching": "Configure response caching and coordination Redis settings",
"transformRequest": "Set up request transformation rules",
"costTracking": "Track and analyze API costs",
"uiTheme": "Customize dashboard appearance",
"tagManagement": "Organize resources with tags",
"prompts": "Manage and version prompt templates",
"skills": "Browse and manage Claude Code skills",
"routerSettings": "Configure routing and load balancing settings",
"telemetry": "Choose which anonymous usage telemetry this proxy collects and see the last report",
"loggingAndAlerts": "Set up logging and alert configurations",
"adminPanel": "Access admin panel and settings"
},
"sidebarAccountMenu": {
"toggles": {
"newBadge": {
"label": "Hide New Feature Indicators",
"ariaLabel": "Toggle hide new feature indicators"
},
"prompts": {
"label": "Hide All Prompts",
"ariaLabel": "Toggle hide all prompts"
},
"blogPosts": {
"label": "Hide Blog Posts",
"ariaLabel": "Toggle hide blog posts"
},
"bouncingIcon": {
"label": "Hide Bouncing Icon",
"ariaLabel": "Toggle hide bouncing icon"
}
},
"triggerLabel": "Account menu — {{role}} — signed in as {{identity}}",
"unknownRole": "Unknown role",
"unknownIdentity": "unknown",
"thanks": "Thanks for using LiteLLM!",
"tier": "Tier",
"premium": "Premium",
"upgradeHint": "Upgrade to Premium for advanced features",
"standard": "Standard",
"role": "Role",
"email": "Email",
"copyEmail": "Copy email",
"userId": "User ID",
"copyUserId": "Copy user ID",
"hideAria": "Hide <name/>",
"toggleHideAria": "Toggle hide {{name}}",
"changePassword": "Change Password",
"logout": "Logout"
},
"sidebarUsageCard": {
"title": "Enterprise usage",
"activePlan": "Active plan",
"loading": "Loading…",
"seats": "Seats",
"teams": "Teams",
"meterValue": "{{used}} of {{total}}"
},
"navbar": {
"expandSidebar": "Expand sidebar",
"collapseSidebar": "Collapse sidebar",
@ -7468,6 +7623,11 @@
"logout": "Logout"
},
"notifications": {
"autoRouter": {
"title": "LiteLLM Auto Router",
"description": "Route every request to the cheapest model that can handle it, no prompt changes needed.",
"link": "Read the docs"
},
"release": {
"newFeatures_one": "{{count}} new feature",
"newFeatures_other": "{{count}} new features",
@ -7497,6 +7657,11 @@
"github": "LiteLLM on GitHub"
},
"dashboardResources": {
"links": {
"docs": "Docs",
"releases": "Releases",
"blog": "Blog"
},
"latestPosts": "Latest posts",
"loadingPosts": "Loading posts…",
"retryPosts": "Retry loading posts",
@ -7593,12 +7758,15 @@
},
"deploymentWarnings": {
"envCredentialLogin": {
"title": "Environment-credential login is enabled",
"fix": "Anyone with <username/>/<password/> (or the master key, when <password/> is unset) can sign in as a proxy admin with a shared static secret. Create a regular admin account with its own password, then set <setting/>"
},
"noRedis": {
"title": "No Redis configured",
"fix": "This proxy runs more than one worker (or the worker count could not be verified). Without Redis, rate limits, budgets, router state and cache invalidation are per worker, so limits are enforced once per worker and spend can overshoot"
},
"detailedDebug": {
"title": "Detailed debug logging is on",
"fix": "<loglevel/> logs extensive diagnostic information and significantly slows the proxy. Use it only while troubleshooting and turn it off in production"
},
"license": {
@ -7883,6 +8051,68 @@
}
},
"shared": {
"credentialOptions": {
"none": "None"
},
"dateRangePresets": {
"today": "Today",
"last7Days": "Last 7 days",
"last30Days": "Last 30 days",
"monthToDate": "Month to date",
"yearToDate": "Year to date"
},
"searchBox": {
"operators": {
"equals": "equals",
"notEquals": "not equals",
"wildcardMatch": "wildcard match",
"doesNotContain": "does not contain"
},
"loadingResults": "Loading results",
"suggestions": "Search suggestions",
"loadingValues": "Loading values…",
"navigate": "Navigate",
"select": "Select",
"copyCommand": {
"copy": "Copy as curl",
"copied": "Copied",
"failed": "Copy failed"
},
"comparisonOperators": "Comparison operators"
},
"inspector": {
"resizePanel": "Resize {{noun}} panel",
"close": "Close (Esc)",
"next": "Next {{noun}} (J)",
"previous": "Previous {{noun}} (K)",
"enterFullScreen": "Enter full screen",
"exitFullScreen": "Exit full screen",
"closeNoun": "Close {{noun}} (Esc)"
},
"inspectorTable": {
"collapse": "Collapse",
"expand": "Expand"
},
"timeline": {
"tooltip": {
"range": "{{start}} to {{end}}",
"failed_one": ", {{count}} failed",
"failed_other": ", {{count}} failed",
"dragToZoom": "drag to zoom"
},
"clearZoom": "Clear time zoom"
},
"durationInput": {
"units": {
"minutes": "minutes",
"hours": "hours",
"days": "days"
},
"unitLabel": "{{label}} unit"
},
"shortcutHints": {
"label": "Keyboard shortcuts"
},
"advancedDatePicker": {
"selectTimeRange": "Select Time Range",
"invalidDateFormat": "Invalid date format",
@ -7997,6 +8227,12 @@
"unknownError": "An unknown error occurred"
},
"inheritedBudgetHint": {
"scopes": {
"team": "Team",
"teamMember": "Team member",
"organization": "Organization",
"user": "User"
},
"memberAlias": "{{member}} in {{team}}",
"intro": "This key has no budget of its own, but its spend still counts toward:"
},
@ -8068,6 +8304,22 @@
"infoLabel": "How {{label}} is calculated"
},
"tableCells": {
"dateCell": {
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
}
},
"autoRouterTag": {
"title": "Routed by auto-router \"{{modelGroup}}\""
},
@ -8140,6 +8392,10 @@
}
},
"networking": {
"connectionTestFailed": "Connection test failed: {{status}} {{statusText}}",
"mcpToolsFetchFailed": "Failed to fetch MCP tools",
"mcpToolsParseFailed": "Failed to parse MCP tools response",
"mcpToolsListFailed": "MCP tools list failed: {{status}} {{statusText}}",
"sessionExpired": "UI Session Expired. Logging out.",
"modelCreated": "Model {{modelName}} created successfully",
"noModelsExist": "No Models Exist. Click Add Model to get started.",
@ -8165,6 +8421,22 @@
}
},
"utils": {
"licenseUtils": {
"noExpiration": "No expiration",
"expired": "Expired {{date}}",
"expires": "Expires {{date}}"
},
"roles": {
"undefinedRole": "Undefined Role",
"appOwner": "App Owner",
"admin": "Admin",
"adminViewer": "Admin Viewer",
"orgAdmin": "Org Admin",
"internalUser": "Internal User",
"internalViewer": "Internal Viewer",
"appUser": "App User",
"unknownRole": "Unknown Role"
},
"dataUtils": {
"copiedToClipboard": "Copied to clipboard",
"copyFailed": "Failed to copy to clipboard"
@ -8176,6 +8448,10 @@
"codeBlock": {
"copyCode": "Copy code"
},
"keyScope": {
"management": "Management",
"readOnly": "Read-only"
},
"chat": {
"page": {
"attachedFiles": "Attached files",
@ -12866,6 +13142,28 @@
}
},
"mcpTools": {
"authTypes": {
"none": "None",
"apiKey": "API Key",
"bearerToken": "Bearer Token",
"token": "Token",
"basic": "Basic Auth",
"authorization": "Authorization (raw header)",
"oauth2": "OAuth",
"oauth2TokenExchange": "OAuth Token Exchange (OBO)",
"oauth2IdJag": "ID-JAG (Okta Cross App Access)",
"awsSigv4": "AWS SigV4 (Bedrock AgentCore MCPs)",
"truePassthrough": "True Passthrough (no LiteLLM auth)",
"oauthDelegate": "OAuth Delegate (client-supplied upstream token)"
},
"transports": {
"http": "Streamable HTTP (Recommended)",
"sse": "Server-Sent Events (SSE)",
"stdio": "Standard Input/Output (stdio)",
"openapi": "OpenAPI Spec"
},
"reachableDescription": "Server responded. Authentication and tools were not checked",
"previewForbidden": "Tool preview is not available for submissions. Tools will be verified by an admin during review.",
"byokCredentialModal": {
"errors": {
"connectFailed": "Failed to connect"

View file

@ -1,3 +1,5 @@
import i18n from "@/i18n/i18n";
export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => {
if (!expirationDate) {
return null;
@ -31,7 +33,9 @@ export const formatExpiryDate = (expirationDate: string): string => {
export const formatExpirationStatus = (expirationDate: string | null, now: Date = new Date()): string => {
const days = getDaysUntilExpiration(expirationDate, now);
if (expirationDate === null || days === null) {
return "No expiration";
return i18n.t("utils.licenseUtils.noExpiration");
}
return days < 0 ? `Expired ${formatExpiryDate(expirationDate)}` : `Expires ${formatExpiryDate(expirationDate)}`;
return days < 0
? i18n.t("utils.licenseUtils.expired", { date: formatExpiryDate(expirationDate) })
: i18n.t("utils.licenseUtils.expires", { date: formatExpiryDate(expirationDate) });
};

View file

@ -1,4 +1,7 @@
import type { ParseKeys } from "i18next";
import { Member, Organization, Team } from "@/components/networking";
import i18n from "@/i18n/i18n";
const ORG_ADMIN_MEMBERSHIP_ROLE = "org_admin";
@ -104,6 +107,21 @@ export const formatUserRole = (userRole: string): string => {
}
};
const USER_ROLE_LABELS: Record<string, ParseKeys> = {
"Undefined Role": "utils.roles.undefinedRole",
"App Owner": "utils.roles.appOwner",
Admin: "utils.roles.admin",
"Admin Viewer": "utils.roles.adminViewer",
"Org Admin": "utils.roles.orgAdmin",
"Internal User": "utils.roles.internalUser",
"Internal Viewer": "utils.roles.internalViewer",
"App User": "utils.roles.appUser",
"Unknown Role": "utils.roles.unknownRole",
};
export const userRoleDisplayLabel = (formattedRole: string): string =>
Object.hasOwn(USER_ROLE_LABELS, formattedRole) ? i18n.t(USER_ROLE_LABELS[formattedRole]) : formattedRole;
export const isOrgAdminSessionRole = (userRole?: string | null): boolean =>
userRole === ORG_ADMIN_MEMBERSHIP_ROLE || userRole === formatUserRole(ORG_ADMIN_MEMBERSHIP_ROLE);